@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,46 @@
1
+ /**
2
+ * Removing a temp directory on Windows, without inventing a product failure.
3
+ *
4
+ * `rm(dir, { recursive: true, force: true })` is the obvious teardown and it is not enough on
5
+ * Windows. Deletion there is not immediate: a handle the OS, the indexer or a virus scanner still
6
+ * holds keeps the entry alive for a moment after the last close, so the directory is observed
7
+ * non-empty at the instant `rmdir` runs and Node throws `ENOTEMPTY`. `force` does not cover it —
8
+ * that suppresses "missing", not "busy".
9
+ *
10
+ * The failure lands in `afterEach`, so it is reported against the test that just PASSED, and the
11
+ * leftover directory then bleeds state into what runs next. On CI this presented as five failures in
12
+ * one file, four of them consequential and one of them the wonderfully misleading "expected length 1
13
+ * but got 2" — a fake production bug manufactured entirely by teardown. This repo already has that
14
+ * exact story written up in `project-store.test.ts` about a timed-out test's stray writes; this is the
15
+ * same failure arriving through the filesystem instead of through the clock.
16
+ *
17
+ * `maxRetries`/`retryDelay` exist in Node for precisely this, and are documented as Windows-oriented.
18
+ * They are a no-op on POSIX, where the first attempt already succeeds.
19
+ */
20
+ import { rm } from 'node:fs/promises';
21
+ /**
22
+ * Retries and backoff. Small and bounded: this is covering a millisecond-scale handle release, so if
23
+ * it has not gone in half a second the cause is not a race and a slower retry would only hide it.
24
+ */
25
+ const MAX_RETRIES = 10;
26
+ const RETRY_DELAY_MS = 50;
27
+ /**
28
+ * Delete a temp directory tree, tolerating Windows' delayed handle release.
29
+ *
30
+ * Never throws: a temp directory that outlives the test is the operating system's problem, not a
31
+ * reason to fail a run or to report a defect the product does not have. What must not happen is the
32
+ * opposite — a red that says nothing about the code under test.
33
+ */
34
+ export async function removeTempDir(dir) {
35
+ try {
36
+ await rm(dir, {
37
+ recursive: true,
38
+ force: true,
39
+ maxRetries: MAX_RETRIES,
40
+ retryDelay: RETRY_DELAY_MS,
41
+ });
42
+ }
43
+ catch {
44
+ // Deliberately swallowed — see above. The OS reclaims the temp directory regardless.
45
+ }
46
+ }
@@ -1,3 +1,5 @@
1
+ import { normalizeQueryArgs } from './query-shape.js';
2
+ import { resolveTargetRef } from './resolve-target.js';
1
3
  /**
2
4
  * Action tools — reticle_act, reticle_act_sequence, reticle_act_and_wait. Split out of tools.ts to keep
3
5
  * that file under the line cap and assembled back into the tool list there via ...ACT_TOOLS; the
@@ -14,15 +16,19 @@ import { buildReactionReport, summarizeReaction } from '../events/reaction.js';
14
16
  import { parsePredicate } from '../events/predicate-parse.js';
15
17
  import { causalSummary } from '../capsule/causal-summary.js';
16
18
  import { findContradictions } from '../events/contradictions.js';
19
+ import { inFlightRequestLabels, repeatedRequestLabels, waitForInFlight, } from './settle-in-flight.js';
20
+ import { waitForReaction } from './react-grace.js';
17
21
  import { decideVerified } from '../honesty/verified.js';
22
+ import { declaredExpectations } from '../events/declared.js';
18
23
  import { readsDomState } from '../honesty/already-true.js';
24
+ import { describeWaitTarget } from '../honesty/unsettled.js';
19
25
  import { saveFailedAssertCapsule } from './act-capsule.js';
20
26
  import { buildDivergenceCapsule } from '../capsule/capsule.js';
21
27
  import { predicateToExpectedLinks } from '../capsule/predicate-to-links.js';
22
28
  import { buildHonestyBlock } from '../honesty/honesty.js';
23
- import { buildCoverageStatement, blindSpotsFromState, transportGapNote, Coverage, impeachesCapture, } from '../honesty/blind-spots.js';
29
+ import { BlindSpotKind, buildCoverageStatement, blindSpotsFromState, transportGapNote, Coverage, impeachesCapture, } from '../honesty/blind-spots.js';
24
30
  import { hasAcceptedWrite } from '../honesty/accepted-write.js';
25
- import { hasUnreadWriteOutcome } from '../honesty/unread-outcome.js';
31
+ import { unreadWriteLabels } from '../honesty/unread-outcome.js';
26
32
  import { evaluatePredicate, waitForPredicate, provenExpectedLinks, PredicateSchema, } from '../events/predicate.js';
27
33
  import { healthEnvelope, refuseIfThrottled } from '../session/session-health.js';
28
34
  import { pausedShortCircuit, pausedOutputShape, withControl, PAUSED_NO_VERDICT, } from '../session/control-envelope.js';
@@ -51,20 +57,59 @@ import { tryRealInput } from './real-input-attempt.js';
51
57
  const ACTION_TYPE_VALUES = Object.values(ActionType);
52
58
  const ACTION_TYPE_LIST = ACTION_TYPE_VALUES.join(' | ');
53
59
  const actionTypeEnum = z.enum(ACTION_TYPE_VALUES);
60
+ /**
61
+ * Resolve an action's element: an explicit `ref`, or a `target` query resolved in the SAME call.
62
+ *
63
+ * Requiring a ref meant every verification paid a `reticle_query` turn first just to learn one
64
+ * string, and the advertised tool surface is re-sent on every turn — measured on the wire, a
65
+ * two-turn verification spent 10,756 of 11,235 tokens on schema and 479 on the actual answers. The
66
+ * lookup still happens; it just stops costing a round trip through the model.
67
+ *
68
+ * `ref` wins when both are given, because it is the more specific instruction and silently
69
+ * preferring the query would act on something the caller did not name.
70
+ */
71
+ async function resolveActTarget(session, args) {
72
+ const ref = asString(args['ref']);
73
+ if (ref !== undefined && ref.length > 0)
74
+ return { kind: 'ref', ref };
75
+ const target = args['target'];
76
+ if (target === undefined) {
77
+ return {
78
+ kind: 'error',
79
+ message: 'pass `ref` (from reticle_query/reticle_snapshot) or `target` (e.g. { testid } or { role, name }).',
80
+ };
81
+ }
82
+ const q = normalizeQueryArgs(asRecord(target));
83
+ const out = await session.command(ReticleCommand.QUERY, {
84
+ by: q['by'],
85
+ value: q['value'],
86
+ name: q['name'],
87
+ scope: q['scope'],
88
+ });
89
+ if (!out.ok)
90
+ return { kind: 'error', message: out.error ?? 'target query failed' };
91
+ const elements = asRecord(out.result)['elements'];
92
+ return resolveTargetRef(Array.isArray(elements) ? elements : []);
93
+ }
54
94
  export const ACT_TOOLS = [
55
95
  {
56
96
  name: ReticleTool.ACT,
57
97
  example: { ref: 'e42', action: 'fill', args: { value: 'hello' } },
58
- description: 'Act WITHOUT checking the result — if the action is supposed to cause something, use reticle_act_and_wait { until } instead and get the verdict in the same call. This tool returns immediately with a `since` cursor; observe the reaction yourself with reticle_observe. Use it for actions with no observable consequence to assert (focus, hover, scrollIntoView, a fill you are about to submit). One action against a ref: click|dblclick|hover|focus|fill|type|clear|select|check|uncheck|submit|press|scrollIntoView. Carries effect:{dispatched,targetMatched,visible,enabled,focusMoved,valueChanged,domMutatedWithin,occluded,occludedBy,scrolledIntoView} to tell "action missed" from "app didn\'t react"; dispatched=landed, settled=a real frame flushed, and a settle timeout never fails the tool. Fields at their uninformative default are OMITTED so a clean action collapses to its consequence: an absent dispatched/targetMatched/visible/enabled means true, an absent occluded/scrolledIntoView/valueChanged/defaultPrevented means false, an absent focusMoved/occludedBy means null. occluded=true means the click point is covered by another element (a real user could not click it) — synthetic dispatch still delivered the event; scrolledIntoView=true means an off-viewport target was scrolled in first. inputMode is "real" (native CDP, no synthetic effect block) or "synthetic"; clicks default to the occlusion-honest synthetic path even when CDP is configured — pass args.native:true to force a trusted native click (file pickers, clipboard). args.holdMs keeps the pointer DOWN for that long between mousedown and mouseup — the only way to drive a hold-to-confirm control (hold-to-delete, hold-to-record, long-press); effect.heldMs reports the hold actually achieved, which a throttled background tab can stretch. args.confirmDangerous is a permission gate and NOT a duration: it allows a destructive control, it does not hold one. inputModeReason explains any real→synthetic choice so it is never silent. Full model (real-input, throttled tabs, `reticle drive`): node_modules/@reticlehq/server/docs/usage.md §18.',
98
+ description: 'Act WITHOUT checking the result — if the action is supposed to cause something, use reticle_act_and_wait { until } instead and get the verdict in the same call. This tool returns immediately with a `since` cursor; observe the reaction yourself with reticle_observe. Use it for actions with no observable consequence to assert (focus, hover, scrollIntoView, a fill you are about to submit). One action against a ref: click|dblclick|hover|focus|fill|type|clear|select|check|uncheck|submit|press|scrollIntoView. Carries effect:{dispatched,targetMatched,visible,enabled,focusMoved,valueChanged,domMutatedWithin,occluded,occludedBy,scrolledIntoView} to tell "action missed" from "app didn\'t react"; dispatched=landed, settled=a real frame flushed, and a settle timeout never fails the tool. Fields at their uninformative default are OMITTED so a clean action collapses to its consequence: an absent dispatched/targetMatched/visible/enabled means true, an absent occluded/scrolledIntoView/valueChanged/defaultPrevented means false, an absent focusMoved/occludedBy means null. occluded=true means the click point is covered by another element (a real user could not click it) — synthetic dispatch still delivered the event; scrolledIntoView=true means an off-viewport target was scrolled in first. effect.alreadyAtValue=true (check/uncheck only) means the box already read as the requested state, so NOTHING was dispatched and the app was never told — the DOM property is not evidence the application holds that value, so assert the app\'s own state (a signal, a request, a derived control), not the box. inputMode is "real" (native CDP, no synthetic effect block) or "synthetic"; clicks default to the occlusion-honest synthetic path even when CDP is configured — pass args.native:true to force a trusted native click (file pickers, clipboard). args.holdMs keeps the pointer DOWN for that long between mousedown and mouseup — the only way to drive a hold-to-confirm control (hold-to-delete, hold-to-record, long-press); effect.heldMs reports the hold actually achieved, which a throttled background tab can stretch. args.confirmDangerous is a permission gate and NOT a duration: it allows a destructive control, it does not hold one. inputModeReason explains any real→synthetic choice so it is never silent. Full model (real-input, throttled tabs, `reticle drive`): node_modules/@reticlehq/server/docs/usage.md §18.',
59
99
  inputSchema: {
60
100
  ref: z
61
101
  .string()
62
- .describe(`Element ref (e.g. 'e42') from reticle_snapshot/reticle_query — stable until the element leaves the DOM, so no re-snapshot between actions.`),
102
+ .optional()
103
+ .describe(`Element ref (e.g. 'e42') from reticle_snapshot/reticle_query — stable until the element leaves the DOM, so no re-snapshot between actions. Give this OR \`target\`.`),
104
+ target: z
105
+ .record(z.unknown())
106
+ .optional()
107
+ .describe('Find the element and act on it in ONE call, instead of a reticle_query round trip first: { testid } | { text } | { role, name } | { label }. Refuses if it matches more than one, rather than guessing.'),
63
108
  action: actionTypeEnum.describe(`Action to perform: ${ACTION_TYPE_LIST}`),
64
109
  args: z
65
110
  .record(z.unknown())
66
111
  .optional()
67
- .describe('Action-specific arguments: { value } for fill/select, { text } for type/press (the key NAME, e.g. Escape or Tab), { toRef } for drag (the ref to drop ON — without it the drag lands nowhere), { native: true } to force a trusted native click, { holdMs: N } to keep the pointer DOWN for N ms (hold-to-confirm controls; effect.heldMs reports what was achieved), { confirmDangerous: true } to allow a potentially destructive control — a permission gate, NOT a duration.'),
112
+ .describe('Action-specific arguments: { value } for fill/select, { text } for type/press (the key NAME, e.g. Escape or Tab), { modifiers: ["Meta","Shift"] } for a press shortcut (Meta/Control/Shift/Alt — a Cmd+K), { toRef } for drag (the ref to drop ON — without it the drag lands nowhere), { native: true } to force a trusted native click, { holdMs: N } to keep the pointer DOWN for N ms (hold-to-confirm controls; effect.heldMs reports what was achieved), { confirmDangerous: true } to allow a potentially destructive control — a permission gate, NOT a duration.'),
68
113
  refuseWhenThrottled: z
69
114
  .boolean()
70
115
  .optional()
@@ -104,10 +149,15 @@ export const ACT_TOOLS = [
104
149
  if (paused !== undefined)
105
150
  return paused;
106
151
  refuseIfThrottled(session, args['refuseWhenThrottled']);
152
+ // Resolve `target` to a ref BEFORE the action window opens, so the lookup is not attributed to
153
+ // the act and cannot be mistaken for something the action caused.
154
+ const targetRef = await resolveActTarget(session, args);
155
+ if ('error' === targetRef.kind)
156
+ throw new Error(targetRef.message);
107
157
  const since = session.elapsed();
108
158
  // The act cursor + effect are marked only once the action has actually DISPATCHED (below, on
109
159
  // each success path) — a refused act must leave nothing behind for the next observe to judge.
110
- const ref = asString(args['ref']) ?? '';
160
+ const ref = targetRef.ref;
111
161
  // Open the journal's action-attribution window BEFORE dispatching, so it covers the native path
112
162
  // too. It used to open only on the synthetic path, and the native path returned above it — so a
113
163
  // hover, a drag or any native:true click produced events with no actionId. Session.pushEvent
@@ -137,7 +187,7 @@ export const ACT_TOOLS = [
137
187
  });
138
188
  }
139
189
  const result = await session.command(ReticleCommand.ACT, {
140
- ref: args['ref'],
190
+ ref: targetRef.ref,
141
191
  action: args['action'],
142
192
  args: args['args'] ?? {},
143
193
  });
@@ -189,11 +239,18 @@ export const ACT_TOOLS = [
189
239
  steps: z
190
240
  .array(z.record(z.unknown()))
191
241
  .describe('Ordered list of { ref, action, args? } objects. Each step is equivalent to one reticle_act call; put confirmDangerous:true in a destructive step args object.'),
242
+ timeout_ms: z
243
+ .number()
244
+ .optional()
245
+ .describe('Per-step timeout in milliseconds. Default: 8000. Each step gets this budget independently.'),
192
246
  ...sessionIdShape,
193
247
  },
194
248
  outputSchema: {
195
249
  since: z.number(),
196
250
  dispatched: z.boolean(),
251
+ completed: z.number(),
252
+ stalled_at: z.number().optional(),
253
+ steps: z.array(z.record(z.unknown())).optional(),
197
254
  result: z.unknown().optional(),
198
255
  session: z
199
256
  .object({ lastSeenMs: z.number(), throttled: z.boolean(), focused: z.boolean() })
@@ -203,33 +260,84 @@ export const ACT_TOOLS = [
203
260
  },
204
261
  handler: async (deps, args) => {
205
262
  const session = deps.sessions.resolve(asString(args['sessionId']));
206
- // Live-control: refuse to drive the page while the human has paused us (before any work).
207
263
  const paused = pausedShortCircuit(session);
208
264
  if (paused !== undefined)
209
265
  return paused;
210
266
  const since = session.elapsed();
211
267
  session.beginAction(ReticleTool.ACT_SEQUENCE, asRecord(args));
212
268
  try {
213
- const result = await session.command(ReticleCommand.ACT_SEQUENCE, { steps: args['steps'] });
214
- if (!result.ok)
215
- throw new Error(result.error ?? 'act_sequence failed');
216
- // Marked only once the sequence ran: a refused sequence must leave no cursor behind for a
217
- // later observe to judge. No single action and no in-target measurement here — per-step
218
- // effects live in steps[] — so the effect is cleared rather than inherited from an earlier act.
219
- session.lastAct.markActed(since, undefined, undefined);
220
- if (deps.recordings.active().length > 0) {
221
- deps.recordings.capture(compileSequenceStep(args, result.result));
269
+ const inputSteps = Array.isArray(args['steps']) ? args['steps'] : [];
270
+ const perStepTimeout = 'number' === typeof args['timeout_ms'] ? args['timeout_ms'] : 8000;
271
+ const stepResults = [];
272
+ let stalledAt;
273
+ // DIVERGENCE: live sends N individual ACT commands (for per-step timeout + progress);
274
+ // replay sends one batched ACT_SEQUENCE command (flows/replay.ts:294). A bug in either is
275
+ // invisible from the other — cover both when changing sequence semantics.
276
+ for (let i = 0; i < inputSteps.length; i++) {
277
+ const step = asRecord(inputSteps[i]);
278
+ try {
279
+ const result = await session.command(ReticleCommand.ACT, { ref: step['ref'], action: step['action'], args: step['args'] ?? {} }, perStepTimeout);
280
+ if (!result.ok) {
281
+ stalledAt = i;
282
+ stepResults.push({
283
+ ref: step['ref'],
284
+ action: step['action'],
285
+ dispatched: false,
286
+ error: result.error ?? 'step failed',
287
+ });
288
+ break;
289
+ }
290
+ const r = asRecord(result.result);
291
+ const stepResult = {
292
+ ref: r['ref'] ?? step['ref'],
293
+ action: r['action'] ?? step['action'],
294
+ dispatched: r['dispatched'] ?? true,
295
+ settled: r['settled'] ?? null,
296
+ settleReason: r['settleReason'] ?? null,
297
+ };
298
+ if (r['testid'] !== undefined)
299
+ stepResult['testid'] = r['testid'];
300
+ if (r['component'] !== undefined)
301
+ stepResult['component'] = r['component'];
302
+ if (r['role'] !== undefined)
303
+ stepResult['role'] = r['role'];
304
+ if (r['name'] !== undefined)
305
+ stepResult['name'] = r['name'];
306
+ if (r['source'] !== undefined)
307
+ stepResult['source'] = r['source'];
308
+ if (r['warning'] !== undefined)
309
+ stepResult['warning'] = r['warning'];
310
+ stepResults.push(stepResult);
311
+ }
312
+ catch (err) {
313
+ stalledAt = i;
314
+ stepResults.push({
315
+ ref: step['ref'],
316
+ action: step['action'],
317
+ dispatched: null,
318
+ timedOut: true,
319
+ error: err instanceof Error ? err.message : 'step timed out',
320
+ });
321
+ break;
322
+ }
323
+ }
324
+ const completed = stalledAt ?? inputSteps.length;
325
+ if (completed > 0) {
326
+ session.lastAct.markActed(since, undefined, undefined);
327
+ }
328
+ if (deps.recordings.active().length > 0 && stalledAt === undefined) {
329
+ deps.recordings.capture(compileSequenceStep(args, { count: inputSteps.length, steps: stepResults }));
222
330
  }
223
- const r = asRecord(result.result); // per-step settle status lives in result.steps[]
224
331
  return withControl(session, {
225
332
  since,
226
- dispatched: r['count'] !== undefined,
227
- result: leanActResult(result.result),
333
+ dispatched: completed > 0,
334
+ completed,
335
+ ...(stalledAt !== undefined ? { stalled_at: stalledAt } : {}),
336
+ steps: stepResults,
228
337
  ...healthEnvelope(session),
229
338
  });
230
339
  }
231
340
  finally {
232
- // Per-step settle lives in steps[]; the sequence action records without a single settle bool.
233
341
  session.finishAction();
234
342
  }
235
343
  },
@@ -250,12 +358,17 @@ export const ACT_TOOLS = [
250
358
  inputSchema: {
251
359
  ref: z
252
360
  .string()
253
- .describe(`Element ref (e.g. 'e42') from reticle_snapshot/reticle_query — stable until the element leaves the DOM, so no re-snapshot between actions.`),
361
+ .optional()
362
+ .describe(`Element ref (e.g. 'e42') from reticle_snapshot/reticle_query — stable until the element leaves the DOM, so no re-snapshot between actions. Give this OR \`target\`.`),
363
+ target: z
364
+ .record(z.unknown())
365
+ .optional()
366
+ .describe('Find the element and act on it in ONE call, instead of a reticle_query round trip first: { testid } | { text } | { role, name } | { label }. Refuses if it matches more than one, rather than guessing.'),
254
367
  action: actionTypeEnum.describe(`Action to perform: ${ACTION_TYPE_LIST}`),
255
368
  args: z
256
369
  .record(z.unknown())
257
370
  .optional()
258
- .describe('Action-specific arguments: { value } for fill/select, { text } for type/press (the key NAME, e.g. Escape or Tab), { toRef } for drag (the ref to drop ON — without it the drag lands nowhere), { confirmDangerous: true } for a potentially destructive control.'),
371
+ .describe('Action-specific arguments: { value } for fill/select, { text } for type/press (the key NAME, e.g. Escape or Tab), { modifiers: ["Meta","Shift"] } for a press shortcut (Meta/Control/Shift/Alt), { toRef } for drag (the ref to drop ON — without it the drag lands nowhere), { confirmDangerous: true } for a potentially destructive control.'),
259
372
  predicate: PredicateSchema.optional().describe('Alias for `until` (the name reticle_assert / reticle_wait_for use).'),
260
373
  until: PredicateSchema.optional().describe('Predicate to wait for after the action completes (same shape as reticle_assert). OMIT to wait for the page to SETTLE — network + DOM idle — the deterministic default instead of a sleep. To assert a consequence AND settle, allOf them: { kind: "allOf", predicates: [<your predicate>, { kind: "settled" }] }.'),
261
374
  timeout_ms: z
@@ -295,7 +408,7 @@ export const ACT_TOOLS = [
295
408
  .describe('Reaction digest: { window_ms, summary } of what the app did (DOM/network/route/console/signal counts). The full per-event timeline is one reticle_observe { since } away.'),
296
409
  summary: z
297
410
  .unknown()
298
- .describe('Bounded causal summary: net {total,errors,headline}, consoleErrors, statePathsChanged, storageKeysChanged, stateDiffs [{path,from,to}], storageDiffs [{key,from,to}], route, signals, layoutShift, longTasks — real before→after diffs (capped), not just readings.'),
411
+ .describe('Bounded causal summary: net {total,errors,headline}, consoleErrors, statePathsChanged, storageKeysChanged, stateDiffs [{path,from,to}], storageDiffs [{key,from,to}], route, signals, layoutShift, longTasks — real before→after diffs (capped), not just readings. THIS IS THE ANSWER TO "what did that click do": console, network, storage and state for this action are all here, so do NOT follow an act with reticle_console + reticle_network + reticle_state to find out — call them only to go deeper into something this block already pointed at. Every list is length-capped so the verdict can never be the field a client truncates; `elided` says which lists lost entries and how many. `stateUnwatched: true` means NO subscribable store is registered, so an empty stateDiffs means unwatched, NOT unchanged — no state conclusion is available until one is registered.'),
299
412
  // Promoted out of `effect` on RED only — the file:line the failure came from, the first thing a
300
413
  // repair wants. Undeclared, it was stripped on the validating profile exactly like the structured
301
414
  // cause above was, losing the highest-value pointer on the one path (a failed verdict) that has it.
@@ -354,6 +467,11 @@ export const ACT_TOOLS = [
354
467
  // Before anything is driven: this path cannot honour a native-input request, and taking the
355
468
  // argument and ignoring it told the agent its trusted click had happened. See act-danger.
356
469
  assertNativeInputSupported(asRecord(args['args']));
470
+ // Resolve `target` to a ref BEFORE the action window opens, so the lookup is not attributed to
471
+ // the act and cannot be mistaken for something the action caused.
472
+ const resolved = await resolveActTarget(session, args);
473
+ if ('error' === resolved.kind)
474
+ throw new Error(resolved.message);
357
475
  const since = session.elapsed();
358
476
  // The cursor + effect are marked after the act dispatches (below) — a refused act leaves none.
359
477
  // The attribution window stays open across the settle wait below, so post-dispatch async events
@@ -369,7 +487,7 @@ export const ACT_TOOLS = [
369
487
  : false;
370
488
  try {
371
489
  const actResult = await session.command(ReticleCommand.ACT, {
372
- ref: args['ref'],
490
+ ref: resolved.ref,
373
491
  action: args['action'],
374
492
  args: args['args'] ?? {},
375
493
  });
@@ -380,9 +498,39 @@ export const ACT_TOOLS = [
380
498
  // stops the spread below from inheriting an earlier reticle_act's action and mutation count.
381
499
  session.lastAct.markActed(since, asString(args['action']), mutatedWithin(asRecord(actResult.result)));
382
500
  // Honesty: floor the predicate at this act's cursor so a stale buffered event can't satisfy it.
501
+ const predicateStarted = session.elapsed();
383
502
  const verdict = timeout > 0
384
503
  ? await waitForPredicate(session, until, timeout, since)
385
504
  : await evaluatePredicate(session, until, since);
505
+ // The predicate resolves the INSTANT it holds, which on an optimistically-navigating app is
506
+ // while the write is still in flight — so the verdict was taken over a window the app had not
507
+ // finished, and came back `unknown / unsettled` asking the CALLER to re-check. A login that
508
+ // genuinely succeeded returned at 492ms of an 8000ms budget with every corroborating channel
509
+ // agreeing. Spend the rest of the budget the caller already granted.
510
+ //
511
+ // This decides nothing: it waits for the answer instead of guessing at it. A response that
512
+ // arrives as a FAILURE still contradicts — and now arrives INSIDE the window, where the
513
+ // detector can see it at all — while a request still open when the budget genuinely runs out
514
+ // reports unsettled exactly as before, an honest limit rather than an early exit. Costs
515
+ // nothing on the common path, where no request is in flight.
516
+ if (verdict.pass && timeout > 0) {
517
+ const sleep = {
518
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
519
+ };
520
+ const spent = () => timeout - (session.elapsed() - predicateStarted);
521
+ await waitForInFlight(session, since, spent(), sleep);
522
+ // Waiting for the response moved the hazard rather than removing it: the response now lands
523
+ // inside the window BY DESIGN, and the app's re-render happens a task or two later. Close
524
+ // the window in that gap and every channel agrees the app took a successful write and did
525
+ // nothing — `response-ignored`, i.e. `verified:"no"` on a correct app, produced entirely by
526
+ // where we stopped looking. A false accusation is the more damaging direction of error for
527
+ // a verification tool: it sends someone to fix code that is not broken.
528
+ //
529
+ // So the response is not the end of the window; the app's REACTION to it is. Paid only in
530
+ // the shape that would otherwise be accused — a successful mutating write with nothing
531
+ // moved after it — and short enough that a genuinely dropped response is still reported.
532
+ await waitForReaction(session, since, spent(), sleep);
533
+ }
386
534
  const r = asRecord(actResult.result);
387
535
  if ('boolean' === typeof r['settled'])
388
536
  settledOutcome = r['settled'];
@@ -411,6 +559,9 @@ export const ACT_TOOLS = [
411
559
  // verdict didn't see everything — say so, never imply full coverage.
412
560
  const spots = blindSpotsFromState(session.blindSpots());
413
561
  const coverage = buildCoverageStatement(spots);
562
+ // Nothing subscribed ⇒ the state channel is dark, and the summary must say so rather than
563
+ // report an empty diff list that reads like a fact about the app. See CausalSummary.
564
+ const stateUnwatched = spots.some((s) => s.kind === BlindSpotKind.UNWATCHED_STATE && s.count > 0);
414
565
  // Only a spot that IMPEACHES the capture belongs in integrity — see impeachesCapture. A
415
566
  // structural boundary (virtualized rows, a cross-origin frame) is reported as coverage and
416
567
  // must not downgrade a verdict about what WAS observed.
@@ -442,6 +593,9 @@ export const ACT_TOOLS = [
442
593
  // evictions that happened outside the window they impeached.
443
594
  truncated: bufferLost,
444
595
  coveragePartial: Coverage.PARTIAL === coverage.coverage,
596
+ // Settlement no longer vetoes a declared consequence that held, so the fact is carried
597
+ // here instead of only in the verdict it used to decide. Omitted when never measured.
598
+ ...(settledOutcome === undefined ? {} : { settled: settledOutcome }),
445
599
  ...(0 === impeachingNotes.length ? {} : { blindSpots: impeachingNotes }),
446
600
  losses,
447
601
  });
@@ -467,17 +621,33 @@ export const ACT_TOOLS = [
467
621
  // Learning material only. Attribution is unchanged — findings still come exclusively from
468
622
  // `windowEvents`, exactly as on the assert path.
469
623
  const prior = since > 0 ? (await session.queryEvents({ since: 0 })).filter((e) => e.t < since) : [];
624
+ //
625
+ // `actionSince` is the window's own floor here, which is the tightest attribution there is —
626
+ // and it is what lets `duplicate-request` claim "one user action was performed" and mean it.
627
+ // What the caller DECLARED before acting, handed to the detector that would otherwise judge
628
+ // the window as if nobody had said what they expected. See events/declared.ts.
629
+ const declared = declaredExpectations(until);
470
630
  const contradictions = findContradictions(windowEvents, {
471
631
  action: acted,
472
632
  prior,
633
+ actionSince: since,
634
+ expectedFailures: declared.netFailures,
635
+ // A consequence that was already true before the action proves nothing about it, so it is
636
+ // not evidence the destination rendered either — `alreadyTrue` decides that, once.
637
+ renderProved: verdict.pass && !alreadyTrue && declared.rendersContent,
473
638
  ...session.lastAct.effect(),
474
639
  });
475
640
  // The single field an agent reads. Everything below it is the evidence it was derived from;
476
641
  // this is the only one that has to be interpreted, and now it interprets itself.
477
642
  const outcomePending = hasAcceptedWrite(windowEvents);
478
- const outcomeUnread = hasUnreadWriteOutcome(windowEvents);
643
+ const outcomeUnread = unreadWriteLabels(windowEvents);
479
644
  const decision = decideVerified({
480
645
  pass: verdict.pass,
646
+ // The caller NAMED the consequence rather than defaulting to "wait for idle". A
647
+ // declaration made before the action is what this tool sells, and idle-settlement was
648
+ // overriding it — see `declaredConsequence`. An explicit `{ kind: "settled" }` is not a
649
+ // declaration about the app's behaviour, it IS the idle wait, so it does not count.
650
+ declaredConsequence: until.kind !== PredicateKind.SETTLED,
481
651
  ...(alreadyTrue ? { alreadyTrue } : {}),
482
652
  // An assertion nobody could evaluate must not be reported as one the app failed.
483
653
  ...(verdict.inconclusive === undefined ? {} : { inconclusive: verdict.inconclusive }),
@@ -488,10 +658,19 @@ export const ACT_TOOLS = [
488
658
  honesty,
489
659
  contradictions,
490
660
  ...(outcomePending ? { outcomePending } : {}),
491
- ...(outcomeUnread ? { outcomeUnread } : {}),
661
+ ...(outcomeUnread.length > 0 ? { outcomeUnread } : {}),
492
662
  // `settled` is genuinely optional: a wait that declared no predicate never measured it, and
493
663
  // passing `false` there would report "never settled" about something never asked to settle.
494
664
  ...(settledOutcome === undefined ? {} : { settled: settledOutcome }),
665
+ // What the wait was for and what was still outstanding when it ended. Read only by the
666
+ // UNSETTLED clauses; supplied always because both of them are reachable from here and the
667
+ // window is already in hand.
668
+ unsettled: {
669
+ waitedFor: describeWaitTarget(until),
670
+ stillInFlight: inFlightRequestLabels(windowEvents),
671
+ // The retry loop that leaves nothing outstanding — see repeatedRequestLabels.
672
+ repeated: repeatedRequestLabels(windowEvents),
673
+ },
495
674
  });
496
675
  return withControl(session, {
497
676
  ...decision,
@@ -505,7 +684,9 @@ export const ACT_TOOLS = [
505
684
  : { source: `${actedSource.file}:${String(actedSource.line)}` }),
506
685
  trace,
507
686
  ...(capsuleSaved === undefined ? {} : { capsuleSaved }),
508
- summary: causalSummary(windowEvents),
687
+ // The window cannot say whether anything was WATCHING state — that is a level fact the
688
+ // session holds. Without it an empty `stateDiffs` reads as "the app changed nothing".
689
+ summary: causalSummary(windowEvents, { stateUnwatched }),
509
690
  // Cross-channel disagreement, reported WITH the action that caused it.
510
691
  //
511
692
  // This is the one finding here a human structurally cannot make — they watch one channel,
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { HonestyGrade } from '../honesty/honesty.js';
14
14
  import type { Predicate } from '../events/predicate.js';
15
- export declare const PRESENCE_ONLY_ADVICE = "This predicate only checks element/text presence, not an observable consequence. A locator healed to the wrong element (or a stale render) can satisfy it while the feature is broken. Prefer a { signal } or { net } assertion \u2014 or allOf it with one \u2014 so green means the feature actually worked.";
15
+ export declare const PRESENCE_ONLY_ADVICE = "This predicate only checks element/text presence, not an observable consequence. A locator healed to the wrong element (or a stale render) can satisfy it while the feature is broken. Prefer a { signal } or { net } assertion (or allOf it with one) so green means the feature actually worked.";
16
16
  /**
17
17
  * True when the predicate asserts ONLY element/text presence with no signal/net consequence anywhere
18
18
  * — the weak pattern worth nudging. A predicate that mixes in a consequence, or that checks something
@@ -12,24 +12,46 @@
12
12
  */
13
13
  import { isConsequenceKind, isPresenceKind, PredicateKind } from '@reticlehq/core';
14
14
  import { HonestyGrade } from '../honesty/honesty.js';
15
- export const PRESENCE_ONLY_ADVICE = 'This predicate only checks element/text presence, not an observable consequence. A locator healed to the wrong element (or a stale render) can satisfy it while the feature is broken. Prefer a { signal } or { net } assertion — or allOf it with one — so green means the feature actually worked.';
16
- function walk(predicate) {
15
+ export const PRESENCE_ONLY_ADVICE = 'This predicate only checks element/text presence, not an observable consequence. A locator healed to the wrong element (or a stale render) can satisfy it while the feature is broken. Prefer a { signal } or { net } assertion (or allOf it with one) so green means the feature actually worked.';
16
+ function walk(predicate, negated = false) {
17
17
  if (PredicateKind.ALL_OF === predicate.kind || PredicateKind.ANY_OF === predicate.kind) {
18
- const subs = predicate.predicates.map(walk);
18
+ const subs = predicate.predicates.map((p) => walk(p, negated));
19
19
  return {
20
20
  consequence: subs.some((s) => s.consequence),
21
21
  presence: subs.some((s) => s.presence),
22
22
  };
23
23
  }
24
+ // Under a NOT, checking content strengthens nothing, so the exemption below must not apply. The
25
+ // nudge warns that a healed or wrong locator can satisfy the predicate, and for an ABSENCE claim a
26
+ // wrong locator satisfies it trivially: "the error message is gone" is true of a selector that
27
+ // never matched anything in the first place. Negation makes a presence check weaker, not stronger.
24
28
  if (PredicateKind.NOT === predicate.kind)
25
- return walk(predicate.predicate);
29
+ return walk(predicate.predicate, true);
26
30
  // Leaf: classify against the single source of truth in core. Kinds that are neither consequence
27
31
  // nor presence (route/console/settled/animation) correctly return false for both.
28
32
  return {
29
33
  consequence: isConsequenceKind(predicate.kind),
30
- presence: isPresenceKind(predicate.kind),
34
+ presence: isPresenceKind(predicate.kind) && (negated || !assertsElementContent(predicate)),
31
35
  };
32
36
  }
37
+ /**
38
+ * An element predicate that checks the element's CONTENT, not merely that it is there.
39
+ *
40
+ * The presence nudge exists because "a locator healed to the wrong element, or a stale render, can
41
+ * satisfy this while the feature is broken". That is exactly true of `{ element, role, name }` and
42
+ * exactly false of `{ element, role, name, value: "274.58" }`: a wrong element now has to carry the
43
+ * same value to pass, which is the thing the nudge is warning cannot be relied on.
44
+ *
45
+ * Kept as a nudge-level distinction rather than a new honesty grade. The grade still reads
46
+ * `presence`, which UNDERSTATES what was proved, and understating is the safe direction for a field
47
+ * whose whole job is to qualify a green. Telling an agent that a value check "only checks presence"
48
+ * is the direction that costs something: it argues for replacing an assertion that is already sound.
49
+ */
50
+ function assertsElementContent(predicate) {
51
+ if (predicate.kind !== PredicateKind.ELEMENT)
52
+ return false;
53
+ return predicate.query?.value !== undefined || predicate.query?.text !== undefined;
54
+ }
33
55
  /**
34
56
  * True when the predicate asserts ONLY element/text presence with no signal/net consequence anywhere
35
57
  * — the weak pattern worth nudging. A predicate that mixes in a consequence, or that checks something
@@ -1,11 +1,25 @@
1
- import { CaptureLoss } from '@reticlehq/core';
1
+ import { BlindSpotKind, CaptureLoss, PredicateKind } from '@reticlehq/core';
2
2
  import { findContradictions } from '../events/contradictions.js';
3
+ import { declaredExpectations } from '../events/declared.js';
3
4
  import { blindSpotsFromState, buildCoverageStatement, Coverage, impeachesCapture, transportGapNote, } from '../honesty/blind-spots.js';
4
5
  import { buildHonestyBlock } from '../honesty/honesty.js';
5
6
  import { hasAcceptedWrite } from '../honesty/accepted-write.js';
6
- import { hasUnreadWriteOutcome } from '../honesty/unread-outcome.js';
7
+ import { unreadWriteLabels } from '../honesty/unread-outcome.js';
7
8
  import { decideVerified } from '../honesty/verified.js';
9
+ import { describeWaitTarget } from '../honesty/unsettled.js';
10
+ import { inFlightRequestLabels, repeatedRequestLabels } from './settle-in-flight.js';
8
11
  import { gradeOfPredicate } from './assert-grade.js';
12
+ function absenceBlindSpotNote(predicate, spots) {
13
+ if (predicate.kind !== 'element' || predicate.absent !== true)
14
+ return undefined;
15
+ const relevant = spots.filter((spot) => spot.count > 0 &&
16
+ spot.kind === BlindSpotKind.CROSS_ORIGIN_IFRAME &&
17
+ undefined !== predicate.query.scope);
18
+ if (0 === relevant.length)
19
+ return undefined;
20
+ const statement = buildCoverageStatement(relevant);
21
+ return `the absence assertion targeted a region Reticle could not observe (${statement.note ?? 'partial coverage'}), so a passing DOM check cannot prove absence`;
22
+ }
9
23
  /**
10
24
  * The honesty verdict for a plain `reticle_assert`.
11
25
  *
@@ -35,6 +49,7 @@ observationLost) {
35
49
  // that implies coverage it never had, and a needless caveat costs the agent a sentence.
36
50
  const spots = blindSpotsFromState(session.blindSpots());
37
51
  const statement = buildCoverageStatement(spots);
52
+ const absenceBlindSpot = absenceBlindSpotNote(predicate, spots);
38
53
  // Omitted entirely when coverage is full, so an intact page pays nothing and the field's PRESENCE
39
54
  // is the warning.
40
55
  const coverage = Coverage.PARTIAL === statement.coverage
@@ -49,9 +64,24 @@ observationLost) {
49
64
  // measured live, `observe` over a wide window reported `unit-mismatch` while `assert` on the same
50
65
  // session reported none. Attribution is unchanged: findings still come only from windowEvents.
51
66
  const prior = since > 0 ? (await session.queryEvents({ since: 0 })).filter((e) => e.t < since) : [];
52
- const contradictions = findContradictions(windowEvents, { prior });
53
- // Only a spot that IMPEACHES the capture downgrades a verdict. A structural boundary (virtualized
54
- // rows, a cross-origin frame) is reported as coverage and must not impugn what WAS observed.
67
+ // The last act, when it falls inside the window being judged. Without it `duplicate-request` counted
68
+ // `method + url` across whatever window the caller asked for, so two legitimate separate saves to one
69
+ // endpoint were reported as a double submit — the instrument accusing the app of something nobody
70
+ // could show it did. An assert taken over a window that predates the act attributes nothing, and the
71
+ // rule then stays quiet.
72
+ const actCursor = session.lastAct.cursor();
73
+ // The same declaration the act path reads. `reticle_assert` is the other half of the verdict
74
+ // surface, and an error path declared here deserves the same answer it gets there — a fix that
75
+ // lived only on the act path would leave the sibling caller broken.
76
+ const declared = declaredExpectations(predicate);
77
+ const contradictions = findContradictions(windowEvents, {
78
+ prior,
79
+ expectedFailures: declared.netFailures,
80
+ renderProved: pass && declared.rendersContent,
81
+ ...(actCursor !== undefined && actCursor >= since ? { actionSince: actCursor } : {}),
82
+ });
83
+ // Only a spot that IMPEACHES the capture downgrades a general verdict. Structural boundaries are
84
+ // reported as coverage; the narrower absence exception is computed separately above.
55
85
  const impeaching = buildCoverageStatement(spots.filter((sp) => impeachesCapture(sp.kind)));
56
86
  // A gap in the WINDOW, as opposed to a standing limit of the page. Both mean the same thing to the
57
87
  // rule — part of what happened was not seen — so both belong in `blindSpots`, which is the only
@@ -59,11 +89,16 @@ observationLost) {
59
89
  const gap = transportGapNote(windowEvents);
60
90
  const impeachingNotes = [impeaching.note, gap].filter((n) => n !== undefined);
61
91
  const outcomePending = hasAcceptedWrite(windowEvents);
62
- const outcomeUnread = hasUnreadWriteOutcome(windowEvents);
92
+ const outcomeUnread = unreadWriteLabels(windowEvents);
63
93
  const decision = decideVerified({
64
94
  pass,
95
+ // Same rule as the act path: the caller named a consequence, so a settlement-only finding must
96
+ // not override it. A fix that lived on one half of the verdict surface would leave the other
97
+ // half broken, and this is the tool agents call most.
98
+ declaredConsequence: predicate.kind !== PredicateKind.SETTLED,
65
99
  ...(inconclusive === undefined ? {} : { inconclusive }),
66
100
  ...(true === observationLost ? { observationLost: true } : {}),
101
+ ...(absenceBlindSpot === undefined ? {} : { absenceBlindSpot }),
67
102
  honesty: buildHonestyBlock({
68
103
  grade: gradeOfPredicate(predicate),
69
104
  attribution: 'window',
@@ -78,7 +113,14 @@ observationLost) {
78
113
  }),
79
114
  contradictions,
80
115
  ...(outcomePending ? { outcomePending } : {}),
81
- ...(outcomeUnread ? { outcomeUnread } : {}),
116
+ ...(outcomeUnread.length > 0 ? { outcomeUnread } : {}),
117
+ // Same detail the act path supplies: this route reaches UNSETTLED through an absence-derived
118
+ // contradiction, and "the window closed before the app finished" is no more actionable here.
119
+ unsettled: {
120
+ waitedFor: describeWaitTarget(predicate),
121
+ stillInFlight: inFlightRequestLabels(windowEvents),
122
+ repeated: repeatedRequestLabels(windowEvents),
123
+ },
82
124
  });
83
125
  return { decision: decision, contradictions, coverage };
84
126
  }