@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
@@ -255,7 +255,9 @@ export const FLOW_TOOLS = [
255
255
  'stale ref. On an anchor MISS returns legible DRIFT { step, anchor, drift:{ reasonKind, reason, ' +
256
256
  'nearest } } (the closest surviving testid) and stops — the "whose fault is it" contract. ' +
257
257
  'Returns { name, status: ok|drift|error, steps:[...] }; missing/malformed files and action ' +
258
- 'failures are status:error with a structured code (distinct from contract-changed drift).',
258
+ 'failures are status:error with a structured code (distinct from contract-changed drift). ' +
259
+ 'An ok replay of a flow that asserts no consequence also carries unverifiable:{reason} — it ' +
260
+ 'would read ok even if the feature were broken, so do NOT treat that ok as proof.',
259
261
  inputSchema: {
260
262
  flow: z
261
263
  .string()
@@ -285,6 +287,12 @@ export const FLOW_TOOLS = [
285
287
  .optional()
286
288
  .describe('Push-default deviation report over the replay segments: ranked deviations vs the learned envelope + a nominal count, or a fall-back note below 3 runs.'),
287
289
  error: z.object({ code: z.string(), message: z.string() }).optional(),
290
+ // Declared here or a validating profile strips it -- the same way `name` was stripped once.
291
+ // A field the handler sets and the schema omits arrives as nothing, silently.
292
+ unverifiable: z
293
+ .object({ reason: z.string() })
294
+ .optional()
295
+ .describe('Set on an ok replay that cannot fail: the flow asserts no observable consequence, so it passes even when the feature is broken. Same reason reticle_flow_verify reports.'),
288
296
  decision: z
289
297
  .object({
290
298
  verdict: z.string(),
@@ -367,23 +375,31 @@ export const FLOW_TOOLS = [
367
375
  requested.length > 0) {
368
376
  const concurrency = resolveConcurrency(requested.length, pool.capacity(), parallelArg);
369
377
  const outcomes = await mapWithConcurrency(requested, concurrency, async (flowName) => {
370
- const lease = await acquireLeasedSession(pool, (id) => deps.sessions.get(id) !== undefined, appUrl);
378
+ const start = deps.now();
379
+ const lease = await acquireLeasedSession(pool, deps.sessions, appUrl, projectId);
371
380
  try {
372
- return await replayNamedFlow(deps, { flowName, sessionId: lease.sessionId });
381
+ const replay = await replayNamedFlow(deps, { flowName, sessionId: lease.sessionId });
382
+ return { replay, durationMs: deps.now() - start };
373
383
  }
374
384
  finally {
375
- // Always release: a crashed flow must never leak a slot and starve the rest of the suite.
376
385
  await lease.release().catch(() => undefined);
377
386
  }
378
387
  });
379
388
  const parallelRuns = await Promise.all(outcomes.map(async (o, i) => {
380
389
  const name = requested[i] ?? '';
381
- const replay = o.ok && o.value !== undefined ? o.value : leaseFailureReplay(name, o.error);
390
+ const replay = o.ok && o.value !== undefined ? o.value.replay : leaseFailureReplay(name, o.error);
382
391
  const loaded = await deps.flows.load(name, projectId).catch(() => null);
383
392
  const flow = loaded !== null && loaded.ok ? loaded.value : undefined;
384
393
  return flow === undefined ? { replay } : { replay, flow };
385
394
  }));
395
+ const timed = outcomes.map((o, i) => ({
396
+ replay: o.ok && o.value !== undefined
397
+ ? o.value.replay
398
+ : leaseFailureReplay(requested[i] ?? '', o.error),
399
+ durationMs: o.ok && o.value !== undefined ? o.value.durationMs : 0,
400
+ }));
386
401
  const flaky = await recordSuiteFlakes(deps.fs, deps.reticleRoot, parallelRuns);
402
+ await persistAndSyncVerificationRun(deps, timed, projectId);
387
403
  const verdict = buildSuiteVerdict(parallelRuns);
388
404
  return flaky.length > 0 ? { ...verdict, flaky: [...flaky] } : verdict;
389
405
  }
@@ -16,7 +16,7 @@ const HEAL_MESSAGES = {
16
16
  NOTHING: 'nothing to heal — every anchor resolved on replay',
17
17
  HEALED: "rewrote drifted testid anchors to their nearest surviving match and re-verified the flow's success consequence still fires",
18
18
  DRIFT_DRY: 'confident rebind(s) proposed — re-run with apply:true to write them to disk',
19
- UNHEALABLE: `drift found, but no nearest match cleared the confidence floor (HEAL_CONFIDENCE_MIN=${HEAL_CONFIDENCE_MIN}); file left untouched — add a data-testid or fix the flow by hand`,
19
+ UNHEALABLE: `drift found, but no nearest match cleared the confidence floor (HEAL_CONFIDENCE_MIN=${HEAL_CONFIDENCE_MIN}); file left untouched; add a data-testid or fix the flow by hand`,
20
20
  HEALED_UNVERIFIED: 'rewrote drifted testid anchors — but this flow declares no success consequence, so the rebind resolves a locator without proving the intent still holds. Add a success-state assertion (reticle_annotate) so future heals can be verified.',
21
21
  CONSEQUENCE_BROKEN: 'rebind resolves the drifted locator to a surviving element, but the healed flow no longer satisfies its success consequence — refusing to write (a heal that loses the intent would ship a green-but-dead test). Fix by hand and verify',
22
22
  };
@@ -247,6 +247,9 @@ export async function replayProgram(session, program, confirmDangerous = false)
247
247
  session.beginAction(ReticleTool.REPLAY, { steps: liveSteps.length });
248
248
  let r;
249
249
  try {
250
+ // DIVERGENCE: replay sends one batched ACT_SEQUENCE command to the browser;
251
+ // live (tools/act-tools.ts) sends N individual ACT commands for per-step timeout +
252
+ // progress. A bug in either is invisible from the other.
250
253
  r = await session.command(ReticleCommand.ACT_SEQUENCE, { steps: liveSteps });
251
254
  }
252
255
  finally {
@@ -10,5 +10,8 @@ import { type ReticleEvent } from '@reticlehq/core';
10
10
  * optimistically rendered "dispatched", the page settled, every channel agreed — and the server
11
11
  * REVERTED it to `held` 1.2 s later. The verdict was not wrong about what it observed. It was early,
12
12
  * and nothing in the response said so except the status code nobody was reading.
13
+ *
14
+ * The DEV TOOLCHAIN's own traffic is excluded, on the same rule every other check applies: a verdict
15
+ * about the app must not be decided by a request the app did not make.
13
16
  */
14
17
  export declare function hasAcceptedWrite(events: readonly ReticleEvent[]): boolean;
@@ -1,5 +1,5 @@
1
- import { EventType, HTTP_ACCEPTED } from '@reticlehq/core';
2
- import { asNumber } from '../tools/tools-helpers.js';
1
+ import { EventType, HTTP_ACCEPTED, isDevToolingUrl } from '@reticlehq/core';
2
+ import { asNumber, asString } from '../tools/tools-helpers.js';
3
3
  /**
4
4
  * Did a write in this window answer `202 Accepted`?
5
5
  *
@@ -11,7 +11,12 @@ import { asNumber } from '../tools/tools-helpers.js';
11
11
  * optimistically rendered "dispatched", the page settled, every channel agreed — and the server
12
12
  * REVERTED it to `held` 1.2 s later. The verdict was not wrong about what it observed. It was early,
13
13
  * and nothing in the response said so except the status code nobody was reading.
14
+ *
15
+ * The DEV TOOLCHAIN's own traffic is excluded, on the same rule every other check applies: a verdict
16
+ * about the app must not be decided by a request the app did not make.
14
17
  */
15
18
  export function hasAcceptedWrite(events) {
16
- return events.some((e) => e.type === EventType.NET_REQUEST && asNumber(e.data['status']) === HTTP_ACCEPTED);
19
+ return events.some((e) => e.type === EventType.NET_REQUEST &&
20
+ asNumber(e.data['status']) === HTTP_ACCEPTED &&
21
+ !isDevToolingUrl(asString(e.data['url'])));
17
22
  }
@@ -17,8 +17,23 @@
17
17
  import { PredicateKind } from '@reticlehq/core';
18
18
  export function readsDomState(predicate) {
19
19
  switch (predicate.kind) {
20
+ // ROUTE is here, and it did not used to be. It was purely event-based — satisfiable only by a
21
+ // navigation inside the act's window, and therefore unable to be answered by the past — so it
22
+ // sat with the floored kinds below.
23
+ //
24
+ // It gained a fallback to the CURRENT route for the case where the window holds no route change,
25
+ // which is what makes "did the session survive a reload?" answerable at all. Without a
26
+ // before-check that fallback is a guaranteed green: the app is already on /login, the click's
27
+ // handler is broken, nothing navigates, and an assertion that the action reached /login passes
28
+ // on the strength of it having been there all along. A bare `{ kind: 'route' }` becomes
29
+ // unconditionally true, because there is always a current route.
30
+ //
31
+ // One extra query on the act path buys `already_true`, reported as UNKNOWN with the reason
32
+ // rather than as a pass. The fallback removed a false red; this is what stops it becoming a
33
+ // false green, and that is the trade this codebase never makes.
20
34
  case PredicateKind.ELEMENT:
21
35
  case PredicateKind.TEXT:
36
+ case PredicateKind.ROUTE:
22
37
  return true;
23
38
  case PredicateKind.ALL_OF:
24
39
  case PredicateKind.ANY_OF:
@@ -7,7 +7,7 @@
7
7
  // The kind enum lives in core (it crosses the wire in a BLIND_SPOT event); re-exported here so existing
8
8
  // honesty-side imports keep working.
9
9
  export { BlindSpotKind } from '@reticlehq/core';
10
- import { BlindSpotKind, EventType } from '@reticlehq/core';
10
+ import { BlindSpotKind, EventType, ReticleEnv, TRANSPORT_LIMITS, } from '@reticlehq/core';
11
11
  /**
12
12
  * Whether the observation window saw everything it was asked about.
13
13
  *
@@ -29,13 +29,21 @@ const LABEL = {
29
29
  [BlindSpotKind.VIRTUALIZED_UNMOUNTED]: (n) => `${String(n)} virtualized unmounted row${1 === n ? '' : 's'} unobserved`,
30
30
  // Not "some rows we could not see" — the events never reached the observer, so this window is a
31
31
  // SAMPLE of what the app did. Phrased as a caveat on what the whole result MEANS.
32
- [BlindSpotKind.RATE_LIMITED]: (n) => `${String(n)} event${1 === n ? '' : 's'} dropped by the bridge rate cap, so this window is SAMPLED — raise RETICLE_MAX_MESSAGES_PER_SECOND for a busy app`,
32
+ // Names a NUMBER, not a variable. "Raise it for a busy app" leaves the reader to invent a value,
33
+ // and the one who reported this had to guess before finding one that worked. The default was
34
+ // raised in the same change, so a page still hitting the cap after that is genuinely unusual and
35
+ // deserves a concrete next value rather than an adjective.
36
+ [BlindSpotKind.RATE_LIMITED]: (n) => `${String(n)} event${1 === n ? '' : 's'} dropped by the bridge rate cap, so this window is SAMPLED. Set ${ReticleEnv.MAX_MESSAGES_PER_SECOND}=${String(TRANSPORT_LIMITS.MAX_MESSAGES_PER_SECOND * 5)} and restart the daemon, then re-run this drive`,
33
37
  // Not a count of things — a single fact about the page. Phrased so the coverage line reads as a
34
38
  // caveat on what the network view MEANS, not as a tally.
35
39
  // A count of unverifiable ACTIONS, not of things we failed to look at. The distinction matters:
36
40
  // nothing here was missed, there was simply never a verdict to observe, and no amount of extra
37
41
  // instrumentation would produce one.
38
42
  [BlindSpotKind.VERDICTLESS_SEND]: (n) => `${String(n)} one-way IPC send${1 === n ? '' : 's'} dispatched with NO verdict — the renderer never learns whether the main process handled ${1 === n ? 'it' : 'them'}, so this cannot be confirmed from the page`,
43
+ // Not a tally — a fact about the app's wiring, phrased as what the EMPTY state channel means. An
44
+ // agent reading `stateDiffs: []` otherwise reads "the app changed no state", which is a claim
45
+ // nothing here can support. Names the one-line fix, because the reader is the one who can apply it.
46
+ [BlindSpotKind.UNWATCHED_STATE]: () => 'no subscribable store is registered, so NO state change is observed — an empty stateDiffs here means unwatched, not unchanged (register the store itself in your reticle-dev module: registerStore(name, store), not a getter)',
39
47
  [BlindSpotKind.WRAPPED_NETWORK]: () => 'fetch was already wrapped before Reticle, so recorded requests may differ from what was sent',
40
48
  // A fact about the app's wiring, not a tally. Says what the empty network view MEANS, because an
41
49
  // empty one here is indistinguishable from "this app made no backend calls" — and that reading is
@@ -33,6 +33,15 @@ interface HonestyInputs {
33
33
  * knows whether a blind-spot note came from the browser transport or from a boundary in the page.
34
34
  */
35
35
  losses?: readonly CaptureLoss[];
36
+ /**
37
+ * Did the page go quiet inside the action's window? Omitted when nothing measured it.
38
+ *
39
+ * Settlement stopped being a veto over a declared consequence that held (see `verified.ts`), and a
40
+ * fact that no longer decides the verdict must not therefore disappear: a page that never goes
41
+ * idle is worth knowing about, and a caller who cares can still gate on it. Reported here rather
42
+ * than beside `verified`, where the answer belongs to the rule and not to its inputs.
43
+ */
44
+ settled?: boolean;
36
45
  }
37
46
  export interface HonestyBlock {
38
47
  grade: HonestyGrade;
@@ -52,6 +61,8 @@ export interface HonestyBlock {
52
61
  issues: string[];
53
62
  losses?: CaptureLoss[];
54
63
  };
64
+ /** Whether the page went quiet in this window — present only when it was measured. */
65
+ settled?: boolean;
55
66
  }
56
67
  export declare function buildHonestyBlock(inputs: HonestyInputs): HonestyBlock;
57
68
  interface HonestyBar {
@@ -46,6 +46,7 @@ export function buildHonestyBlock(inputs) {
46
46
  ? {}
47
47
  : { envelope: { samples, sufficient: samples >= MIN_ENVELOPE_SAMPLES } }),
48
48
  coverage: { ...(pct === undefined ? {} : { pct }), partial },
49
+ ...(inputs.settled === undefined ? {} : { settled: inputs.settled }),
49
50
  integrity: {
50
51
  clean: 0 === issues.length,
51
52
  issues,
@@ -8,12 +8,14 @@
8
8
  * every other field in the record agrees is a clean 200.
9
9
  *
10
10
  * So an omission is declared rather than left to inference, with the one line that fixes it. Present
11
- * only when a body-bearing call was returned AND none of them carried a body, so an app that already
12
- * captures bodies pays nothing and the field's PRESENCE is the warning.
11
+ * only when a body-bearing call was returned AND no body — request or response — was recorded
12
+ * anywhere in the result, so an app that already captures bodies pays nothing and the field's
13
+ * PRESENCE is the warning.
13
14
  */
14
15
  export declare function bodiesNotCaptured(calls: {
15
16
  method?: string;
16
17
  requestBody?: string;
18
+ responseBody?: string;
17
19
  }[]): {
18
20
  bodiesNotCaptured?: string;
19
21
  };
@@ -10,13 +10,20 @@ const BODY_BEARING_METHODS = new Set(['POST', 'PUT', 'PATCH']);
10
10
  * every other field in the record agrees is a clean 200.
11
11
  *
12
12
  * So an omission is declared rather than left to inference, with the one line that fixes it. Present
13
- * only when a body-bearing call was returned AND none of them carried a body, so an app that already
14
- * captures bodies pays nothing and the field's PRESENCE is the warning.
13
+ * only when a body-bearing call was returned AND no body — request or response — was recorded
14
+ * anywhere in the result, so an app that already captures bodies pays nothing and the field's
15
+ * PRESENCE is the warning.
15
16
  */
16
17
  export function bodiesNotCaptured(calls) {
17
18
  const bodyBearing = calls.filter((c) => BODY_BEARING_METHODS.has((c.method ?? '').toUpperCase()));
18
19
  if (0 === bodyBearing.length)
19
20
  return {};
21
+ // A recorded response body anywhere in the result proves capture is ON. An absent request body
22
+ // then means this payload was not stringified (a multipart upload, a body the SDK skipped), not
23
+ // that recording is off — and firing the note would contradict the body sitting in the same
24
+ // result, exactly on the question the note exists to answer. (#394)
25
+ if (calls.some((c) => c.responseBody !== undefined))
26
+ return {};
20
27
  if (bodyBearing.some((c) => c.requestBody !== undefined))
21
28
  return {};
22
29
  return {
@@ -1,2 +1,10 @@
1
1
  import { type ReticleEvent } from '@reticlehq/core';
2
+ /**
3
+ * The writes whose outcome went unread, as "METHOD url".
4
+ *
5
+ * Named rather than counted, because a caveat an agent cannot locate is a caveat it learns to skip.
6
+ * The sentence used to say "a write returned 2xx with a response body that was never recorded" and
7
+ * name no write, which is unactionable on any page that makes more than one call.
8
+ */
9
+ export declare function unreadWriteLabels(events: readonly ReticleEvent[]): string[];
2
10
  export declare function hasUnreadWriteOutcome(events: readonly ReticleEvent[]): boolean;
@@ -1,4 +1,5 @@
1
- import { EventType, MUTATING_METHODS, NetInitiator } from '@reticlehq/core';
1
+ import { EventType, MUTATING_METHODS, NetInitiator, isDevToolingUrl, } from '@reticlehq/core';
2
+ import { asString } from '../tools/tools-helpers.js';
2
3
  const IPC_METHOD = NetInitiator.IPC.toUpperCase();
3
4
  /**
4
5
  * A write whose outcome is sitting in a payload nobody read.
@@ -29,22 +30,48 @@ const IPC_METHOD = NetInitiator.IPC.toUpperCase();
29
30
  * The residual gap is real and narrower: a handler that RESOLVES with a failure inside its payload
30
31
  * is invisible without body capture. That case is `findBodyFailures`, which reads the payload and
31
32
  * catches it precisely — with capture on, where it belongs.
33
+ *
34
+ * - **the app's own traffic only.** The DEV TOOLCHAIN writes too: Next's overlay answers
35
+ * `POST /__nextjs_original-stack-frames` with a JSON body the moment the app logs one React
36
+ * warning, and nothing captures it. Judged as the app's write, that made every verdict in a Next
37
+ * dev app `unknown` — decided by a channel the caller never asked about, on a request the app did
38
+ * not make. `findContradictions` has split this traffic out for the same reason since the overlay
39
+ * turned correct navigations red; this rule was reading the unsplit window.
32
40
  */
33
41
  const OK_MIN = 200;
34
42
  const OK_MAX = 300;
35
- export function hasUnreadWriteOutcome(events) {
36
- return events.some((event) => {
43
+ /**
44
+ * The writes whose outcome went unread, as "METHOD url".
45
+ *
46
+ * Named rather than counted, because a caveat an agent cannot locate is a caveat it learns to skip.
47
+ * The sentence used to say "a write returned 2xx with a response body that was never recorded" and
48
+ * name no write, which is unactionable on any page that makes more than one call.
49
+ */
50
+ export function unreadWriteLabels(events) {
51
+ const labels = [];
52
+ for (const event of events) {
37
53
  if (event.type !== EventType.NET_REQUEST)
38
- return false;
54
+ continue;
39
55
  const method = ('string' === typeof event.data['method'] ? event.data['method'] : '').toUpperCase();
40
56
  if (method === IPC_METHOD || !MUTATING_METHODS.includes(method))
41
- return false;
57
+ continue;
58
+ const url = asString(event.data['url']);
59
+ if (isDevToolingUrl(url))
60
+ continue;
42
61
  const status = event.data['status'];
43
62
  if (typeof status !== 'number' || status < OK_MIN || status >= OK_MAX)
44
- return false;
63
+ continue;
45
64
  if (event.data['responseBody'] !== undefined)
46
- return false; // read — findBodyFailures judged it
65
+ continue; // read — findBodyFailures judged it
47
66
  const size = event.data['responseSize'];
48
- return 'number' === typeof size && size > 0;
49
- });
67
+ if ('number' !== typeof size || size <= 0)
68
+ continue;
69
+ const label = `${method} ${url ?? ''}`.trim();
70
+ if (!labels.includes(label))
71
+ labels.push(label);
72
+ }
73
+ return labels;
74
+ }
75
+ export function hasUnreadWriteOutcome(events) {
76
+ return unreadWriteLabels(events).length > 0;
50
77
  }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * What an `unsettled` verdict was waiting for, and what its window held instead.
3
+ *
4
+ * `unsettled` is the commonest reason a verdict comes back `unknown`, and all it said was that the
5
+ * page "never settled" — a sentence equally true of a page that polls, a page that animates, and a
6
+ * page whose write is still open. Those need three different next moves, and an agent handed the
7
+ * sentence cannot tell which it is holding, so the drive stops there. An `unknown` that explains
8
+ * itself is a retry; one that does not is a dead end.
9
+ *
10
+ * Two releases already tuned this window and neither is undone here. The budget is still spent in
11
+ * full before anything is called unsettled (settle-in-flight.ts), and the app is still given its
12
+ * reaction window before any accusation (waitForReaction). This only describes what was true at the
13
+ * moment the budget genuinely ran out.
14
+ *
15
+ * It rides in `because`, beside `verifiedReason`, rather than in a field of its own: the verdict has
16
+ * one sentence an agent is known to read, and a caveat parked anywhere else is a caveat nobody reads.
17
+ */
18
+ import type { Predicate } from '../events/predicate.js';
19
+ export interface UnsettledWindow {
20
+ /** What the wait was for, in the caller's own terms — see `describeWaitTarget`. */
21
+ waitedFor: string;
22
+ /** Requests that started in this window and had not come back, as "METHOD url". */
23
+ stillInFlight: readonly string[];
24
+ /**
25
+ * Endpoints called more than once in this window, as "METHOD url ×N" — see `repeatedRequestLabels`.
26
+ *
27
+ * The case `stillInFlight` structurally cannot see: a retry loop against a dead backend completes
28
+ * every attempt, so nothing is ever outstanding, and the verdict fell through to a sentence that
29
+ * blamed unnamed churn on an app whose whole problem was one endpoint being hammered. Optional —
30
+ * a caller that does not supply it gets exactly the sentence that shipped before.
31
+ */
32
+ repeated?: readonly string[];
33
+ }
34
+ /**
35
+ * The wait, in one phrase.
36
+ *
37
+ * The default matters most: a call that declares no `until` waits for the page to go IDLE, and an
38
+ * agent that never chose that wait cannot be expected to recognise it in a timeout about it.
39
+ */
40
+ export declare function describeWaitTarget(predicate: Predicate): string;
41
+ /**
42
+ * The `because` sentence for an unsettled verdict: the wait, the observation, the next move.
43
+ *
44
+ * The two branches are the point. An outstanding write is worth waiting for and worth asserting
45
+ * directly; page churn is neither, and telling an agent to raise its timeout on churn sells it a
46
+ * slower dead end. Without the detail the sentence is the one that shipped before, so a caller that
47
+ * cannot supply the facts loses nothing.
48
+ */
49
+ export declare function unsettledBecause(base: string, window: UnsettledWindow | undefined): string;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * What an `unsettled` verdict was waiting for, and what its window held instead.
3
+ *
4
+ * `unsettled` is the commonest reason a verdict comes back `unknown`, and all it said was that the
5
+ * page "never settled" — a sentence equally true of a page that polls, a page that animates, and a
6
+ * page whose write is still open. Those need three different next moves, and an agent handed the
7
+ * sentence cannot tell which it is holding, so the drive stops there. An `unknown` that explains
8
+ * itself is a retry; one that does not is a dead end.
9
+ *
10
+ * Two releases already tuned this window and neither is undone here. The budget is still spent in
11
+ * full before anything is called unsettled (settle-in-flight.ts), and the app is still given its
12
+ * reaction window before any accusation (waitForReaction). This only describes what was true at the
13
+ * moment the budget genuinely ran out.
14
+ *
15
+ * It rides in `because`, beside `verifiedReason`, rather than in a field of its own: the verdict has
16
+ * one sentence an agent is known to read, and a caveat parked anywhere else is a caveat nobody reads.
17
+ */
18
+ import { PredicateKind } from '@reticlehq/core';
19
+ /** Enough to recognise the one that is stuck; not so many that a chatty window eats the verdict. */
20
+ const MAX_LISTED_REQUESTS = 3;
21
+ /**
22
+ * The wait, in one phrase.
23
+ *
24
+ * The default matters most: a call that declares no `until` waits for the page to go IDLE, and an
25
+ * agent that never chose that wait cannot be expected to recognise it in a timeout about it.
26
+ */
27
+ export function describeWaitTarget(predicate) {
28
+ switch (predicate.kind) {
29
+ case PredicateKind.SETTLED:
30
+ return 'the page to go idle';
31
+ case PredicateKind.SIGNAL:
32
+ return predicate.name === undefined ? 'any signal' : `signal '${predicate.name}'`;
33
+ case PredicateKind.NET:
34
+ return `a request${predicate.method === undefined ? '' : ` ${predicate.method}`}${predicate.urlContains === undefined ? '' : ` matching '${predicate.urlContains}'`}`;
35
+ case PredicateKind.ROUTE:
36
+ return `a route change${predicate.pathname === undefined ? '' : ` to '${predicate.pathname}'`}${predicate.contains === undefined ? '' : ` containing '${predicate.contains}'`}`;
37
+ case PredicateKind.STATE:
38
+ return `state at '${predicate.path}'`;
39
+ case PredicateKind.TEXT:
40
+ return `text containing '${predicate.contains}'`;
41
+ case PredicateKind.ELEMENT:
42
+ return `an element matching ${JSON.stringify(predicate.query)}`;
43
+ case PredicateKind.CONSOLE:
44
+ return 'a console message';
45
+ case PredicateKind.ANIMATION:
46
+ return predicate.name === undefined ? 'an animation' : `animation '${predicate.name}'`;
47
+ case PredicateKind.ALL_OF:
48
+ return `all of (${predicate.predicates.map(describeWaitTarget).join('; ')})`;
49
+ case PredicateKind.ANY_OF:
50
+ return `any of (${predicate.predicates.map(describeWaitTarget).join('; ')})`;
51
+ case PredicateKind.NOT:
52
+ return `the absence of ${describeWaitTarget(predicate.predicate)}`;
53
+ }
54
+ }
55
+ /**
56
+ * The `because` sentence for an unsettled verdict: the wait, the observation, the next move.
57
+ *
58
+ * The two branches are the point. An outstanding write is worth waiting for and worth asserting
59
+ * directly; page churn is neither, and telling an agent to raise its timeout on churn sells it a
60
+ * slower dead end. Without the detail the sentence is the one that shipped before, so a caller that
61
+ * cannot supply the facts loses nothing.
62
+ */
63
+ export function unsettledBecause(base, window) {
64
+ if (window === undefined)
65
+ return `${base} — re-check, or assert a consequence that settles`;
66
+ if (0 === window.stillInFlight.length) {
67
+ const repeated = window.repeated ?? [];
68
+ return (`${base}. It waited for ${window.waitedFor}, and no request started in this window was left ` +
69
+ 'open — what kept the page busy was its own churn ' +
70
+ (0 === repeated.length
71
+ ? '(a poll, a timer, an animation)'
72
+ : `(these fired repeatedly: ${repeated.join(', ')} — a poll or a retrying query)`) +
73
+ ', so waiting longer will not help. Assert the consequence you care about (a signal, a route ' +
74
+ 'change, or store state) instead of waiting for idle, and re-check if the app is genuinely slow');
75
+ }
76
+ const shown = window.stillInFlight.slice(0, MAX_LISTED_REQUESTS);
77
+ const rest = window.stillInFlight.length - shown.length;
78
+ return (`${base}. It waited for ${window.waitedFor}, and ${String(window.stillInFlight.length)} ` +
79
+ `request(s) started in this window had not come back: ${shown.join(', ')}` +
80
+ `${rest > 0 ? `, and ${String(rest)} more` : ''} — re-check once they land, raise timeout_ms, ` +
81
+ 'or assert the request itself so the wait ends on the response rather than on idle');
82
+ }
@@ -1,5 +1,6 @@
1
1
  import { Verified, VerifiedReason } from '@reticlehq/core';
2
2
  import { type HonestyBlock } from './honesty.js';
3
+ import { type UnsettledWindow } from './unsettled.js';
3
4
  /**
4
5
  * The decision rule: eight trust dimensions in, one answer out.
5
6
  *
@@ -41,16 +42,48 @@ interface VerifiedInputs {
41
42
  }[];
42
43
  /** Did a real frame flush before the wait gave up? */
43
44
  settled?: boolean;
45
+ /**
46
+ * The caller NAMED the expected consequence before acting — an `until`/`predicate` of its own,
47
+ * rather than the default "wait for the page to go idle".
48
+ *
49
+ * This is the epistemic core of the tool: a declaration made before the action cannot be
50
+ * rationalised after it. When such a declaration HOLDS, settlement is corroboration, not a veto —
51
+ * so the two clauses that answer "the app had not gone quiet" step aside for it. Measured in the
52
+ * field on three different apps: the expected text was on screen, the write returned 204, the
53
+ * nested verdict passed with evidence, and the verdict was `unknown` because the SPA polls, or
54
+ * because the tab was hidden and a hidden tab never fires the animation frame `settled` is read
55
+ * from. A hidden tab is the NORMAL state for agent-driven verification, so a signal that is always
56
+ * false there cannot be a precondition for a verdict.
57
+ *
58
+ * Narrow on purpose. It steps aside for settlement ONLY: an absence-derived finding about what the
59
+ * action DID — a duplicate write, a dead control, a route that rendered nothing, a response
60
+ * nothing consumed — still downgrades, and every positively OBSERVED contradiction still wins
61
+ * outright. Fixing a false negative must not open a path to a false positive.
62
+ */
63
+ declaredConsequence?: boolean;
44
64
  /**
45
65
  * A write in this window answered `202 Accepted` — the server took the request and has NOT
46
66
  * finished processing it.
47
67
  */
48
68
  outcomePending?: boolean;
49
69
  /**
50
- * A write in this window returned 2xx with a payload that was never recorded, so its outcome was
51
- * never read. See `hasUnreadWriteOutcome` — the status line describes the transport, not the result.
70
+ * Writes in this window that returned 2xx with a payload that was never recorded, as "METHOD url",
71
+ * so their outcome was never read. See `unreadWriteLabels` — the status line describes the
72
+ * transport, not the result.
73
+ *
74
+ * The LABELS rather than a flag: every other clause names the evidence that decided it, and this
75
+ * one named nothing, so an agent holding the caveat could not tell which call it was about.
76
+ * Empty means nothing went unread.
77
+ */
78
+ outcomeUnread?: readonly string[];
79
+ /**
80
+ * What the wait was for and what the window held when it ended — read ONLY by the two clauses that
81
+ * answer UNSETTLED, which is the commonest reason a verdict comes back `unknown` and was also the
82
+ * least actionable. Optional: without it those clauses say exactly what they said before.
52
83
  */
53
- outcomeUnread?: boolean;
84
+ unsettled?: UnsettledWindow;
85
+ /** A passing absence assertion targeted a region that the current capture could not observe. */
86
+ absenceBlindSpot?: string;
54
87
  }
55
88
  interface VerifiedVerdict {
56
89
  verified: Verified;
@@ -1,7 +1,10 @@
1
- import { Verified, VerifiedReason, isAbsenceDerived } from '@reticlehq/core';
1
+ import { ContradictionKind, MUTATING_METHODS, Verified, VerifiedReason, isAbsenceDerived, } from '@reticlehq/core';
2
2
  import { HonestyGrade } from './honesty.js';
3
+ import { unsettledBecause } from './unsettled.js';
3
4
  export function decideVerified(inputs) {
4
5
  const { pass, honesty, contradictions = [], settled, outcomePending, outcomeUnread } = inputs;
6
+ /** The caller named a consequence before acting and it held — see `declaredConsequence`. */
7
+ const declaredHeld = true === inputs.declaredConsequence && true === pass;
5
8
  // Ahead of the failure clause, because a failure is only the most actionable fact when there WAS
6
9
  // one. An assertion nobody could evaluate is not a defect in the app, and calling it one was
7
10
  // putting the agent's own malformed calls into the bug count.
@@ -63,13 +66,40 @@ export function decideVerified(inputs) {
63
66
  // Absence-derived only: report it, do not assert failure. UNKNOWN is the honest answer — Reticle
64
67
  // drove the app and could not yet tell. The finding still rides out in `contradictions`, so an
65
68
  // agent that wants to wait and re-check has everything it needs.
66
- if (contradictions.length > 0) {
69
+ //
70
+ // ...except when the only finding is that a READ had not come back AND the caller declared the
71
+ // consequence that did. `request-never-settled` is a statement about when we stopped looking; the
72
+ // declaration is a statement about what was supposed to happen, made before it could. On a page
73
+ // that polls, the first is permanently true and vetoed every verdict the second earned.
74
+ //
75
+ // An open WRITE is the exception to the exception, and it is where the false-green defence lives:
76
+ // "the toast said Saved while the POST was still open" is the archetype this product exists to
77
+ // catch, and its outcome genuinely does not exist yet. A poll or a prefetch left hanging says
78
+ // nothing about the action; an unfinished mutation says the answer is not in yet. Read off the
79
+ // labels the window already carries — and when there are no labels to read, nothing is softened.
80
+ const openWrite = (inputs.unsettled?.stillInFlight ?? []).some((label) => MUTATING_METHODS.includes((label.split(' ')[0] ?? '').toUpperCase()));
81
+ const settlementOnly = declaredHeld &&
82
+ inputs.unsettled !== undefined &&
83
+ !openWrite &&
84
+ contradictions.every((c) => c.kind === ContradictionKind.REQUEST_NEVER_SETTLED);
85
+ if (contradictions.length > 0 && !settlementOnly) {
67
86
  const kinds = contradictions.map((c) => c.kind).join(', ');
68
87
  return {
69
88
  verified: Verified.UNKNOWN,
70
- verifiedReason: VerifiedReason.UNSETTLED,
71
- because: `the assertion held, but this window closed before the app finished (${kinds}) — ` +
72
- `re-check, or assert a consequence that settles`,
89
+ // NOT `unsettled`: this clause fires whether or not the page went idle, and naming it after
90
+ // idle produced verdicts that read `unsettled` beside `settled: true`.
91
+ verifiedReason: VerifiedReason.EVIDENCE_INCOMPLETE,
92
+ because: unsettledBecause(`the assertion held, but this window closed before the app finished (${kinds})`, inputs.unsettled),
93
+ };
94
+ }
95
+ // An absence assertion over a region Reticle cannot observe is not disproved, but it is not proved
96
+ // either. This is narrower than general partial coverage: the target itself is scoped to the blind
97
+ // region, so the DOM result cannot answer the question the caller asked.
98
+ if (inputs.absenceBlindSpot !== undefined) {
99
+ return {
100
+ verified: Verified.UNKNOWN,
101
+ verifiedReason: VerifiedReason.ABSENCE_BLIND_SPOT,
102
+ because: inputs.absenceBlindSpot,
73
103
  };
74
104
  }
75
105
  // Below the contradiction clause, and for the same reason a contradiction outranks a dirty
@@ -132,19 +162,26 @@ export function decideVerified(inputs) {
132
162
  //
133
163
  // UNKNOWN, not NO: nothing is known to have failed. The remedy is in the sentence, because an
134
164
  // agent that cannot act on a caveat will learn to skip it.
135
- if (true === outcomeUnread) {
165
+ if (outcomeUnread !== undefined && outcomeUnread.length > 0) {
136
166
  return {
137
167
  verified: Verified.UNKNOWN,
138
168
  verifiedReason: VerifiedReason.OUTCOME_UNREAD,
139
- because: 'a write returned 2xx with a response body that was never recorded, so its outcome is unread — a 200 describes the transport, not the result (a batch reports per-item failures in the body, and every GraphQL error is a 200). Enable it where your app calls connect(): `reticle.connect({ captureNetworkBodies: true })`, then re-run',
169
+ because: `a write returned 2xx with a response body that was never recorded (${outcomeUnread.join('; ')}), so its outcome is unread` +
170
+ ' — a 200 describes the transport, not the result (a batch reports per-item failures in the body, and every GraphQL error is a 200). Enable it where your app calls connect(): `reticle.connect({ captureNetworkBodies: true })`, then re-run',
140
171
  };
141
172
  }
142
173
  // Never settled: the page may still be moving, so the observation window may have closed early.
143
- if (false === settled) {
174
+ //
175
+ // Skipped when the caller DECLARED the consequence and it held. `settled` here is "a real
176
+ // animation frame fired within the budget", which a hidden or throttled tab never does — and a
177
+ // hidden tab is the ordinary case for agent-driven verification. It is corroboration; it is not
178
+ // the claim. It is still reported: `honesty.settled` carries the fact, and the sentence below says
179
+ // so in words.
180
+ if (false === settled && !declaredHeld) {
144
181
  return {
145
182
  verified: Verified.UNKNOWN,
146
183
  verifiedReason: VerifiedReason.UNSETTLED,
147
- because: 'the page never settled, so the reaction window may have closed before the app finished',
184
+ because: unsettledBecause('the page never settled, so the reaction window may have closed before the app finished', inputs.unsettled),
148
185
  };
149
186
  }
150
187
  // Partial coverage is a real caveat but not a blocker — it is reported in `honesty.coverage` and
@@ -155,11 +192,17 @@ export function decideVerified(inputs) {
155
192
  // partial` — the prose contradicting the evidence block next to it. Whichever a reader believes,
156
193
  // one of them lied, and the whole point of this layer is that the sentence can be trusted on its
157
194
  // own. Seen on a one-way IPC send: coverage said the outcome was unobservable, `because` said clean.
195
+ // A green that rests on the declaration rather than on idle says so. The page not going quiet is a
196
+ // real fact about the app — a poll, a timer, a throttled tab — and a caller who cares must be able
197
+ // to see it without re-deriving it, so it is stated here and carried in `honesty.settled`.
198
+ const notIdle = false === settled
199
+ ? ', though the page never went idle — this rests on the consequence you declared, not on the page going quiet'
200
+ : '';
158
201
  return {
159
202
  verified: Verified.YES,
160
203
  verifiedReason: VerifiedReason.PROVED,
161
204
  because: true === honesty.coverage?.partial
162
- ? `assertion held at ${honesty.grade} grade with no channel disagreeing, but coverage was PARTIAL — see \`coverage\` for what went unobserved`
163
- : `assertion held at ${honesty.grade} grade over a clean capture with no channel disagreeing`,
205
+ ? `assertion held at ${honesty.grade} grade with no channel disagreeing, but coverage was PARTIAL — see \`coverage\` for what went unobserved${notIdle}`
206
+ : `assertion held at ${honesty.grade} grade over a clean capture with no channel disagreeing${notIdle}`,
164
207
  };
165
208
  }