@reticlehq/server 2.6.0 → 2.8.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 (276) hide show
  1. package/README.md +14 -5
  2. package/SKILL.md +251 -33
  3. package/dist/bridge/bridge.d.ts +6 -1
  4. package/dist/bridge/bridge.js +27 -4
  5. package/dist/capsule/causal-summary.d.ts +20 -0
  6. package/dist/capsule/causal-summary.js +31 -6
  7. package/dist/capsule/divergence.js +7 -1
  8. package/dist/cli/chromium-hint.d.ts +68 -0
  9. package/dist/cli/chromium-hint.js +145 -0
  10. package/dist/cli/cli-doctor.js +31 -36
  11. package/dist/cli/cli-kill.d.ts +58 -0
  12. package/dist/cli/cli-kill.js +162 -0
  13. package/dist/cli/cli-launch.d.ts +18 -0
  14. package/dist/cli/cli-launch.js +32 -1
  15. package/dist/cli/cli-parse.d.ts +16 -1
  16. package/dist/cli/cli-parse.js +32 -9
  17. package/dist/cli/cli-port.d.ts +8 -0
  18. package/dist/cli/cli-port.js +36 -1
  19. package/dist/cli/cli-verify.js +2 -2
  20. package/dist/cli/doctor-daemon-line.js +10 -1
  21. package/dist/cli/doctor-sessions-line.d.ts +12 -0
  22. package/dist/cli/doctor-sessions-line.js +66 -0
  23. package/dist/cli/port-holder.d.ts +10 -0
  24. package/dist/cli/port-holder.js +26 -2
  25. package/dist/cli.js +139 -23
  26. package/dist/crawl/crawl-tools.js +12 -2
  27. package/dist/crawl/crawl.d.ts +13 -0
  28. package/dist/crawl/crawl.js +50 -2
  29. package/dist/daemon/daemon-resilience.js +2 -2
  30. package/dist/daemon/loopback-alias.d.ts +32 -0
  31. package/dist/daemon/loopback-alias.js +80 -0
  32. package/dist/events/contradictions.d.ts +14 -0
  33. package/dist/events/contradictions.js +26 -17
  34. package/dist/events/predicate-eval.d.ts +26 -3
  35. package/dist/events/predicate-eval.js +284 -95
  36. package/dist/events/predicate-parse.d.ts +1 -1
  37. package/dist/events/predicate-parse.js +18 -4
  38. package/dist/events/predicate.js +170 -18
  39. package/dist/events/ring-buffer.d.ts +8 -0
  40. package/dist/events/ring-buffer.js +34 -1
  41. package/dist/flows/flow-classify.js +1 -1
  42. package/dist/flows/flow-tools.js +12 -4
  43. package/dist/flows/heal-run.js +1 -1
  44. package/dist/honesty/blind-spots.js +6 -2
  45. package/dist/honesty/honesty.d.ts +11 -0
  46. package/dist/honesty/honesty.js +9 -1
  47. package/dist/honesty/unsettled.d.ts +40 -0
  48. package/dist/honesty/unsettled.js +78 -0
  49. package/dist/honesty/verified.d.ts +7 -0
  50. package/dist/honesty/verified.js +27 -4
  51. package/dist/http-server.js +11 -1
  52. package/dist/index.js +29 -2
  53. package/dist/init/agent-rules.d.ts +16 -0
  54. package/dist/init/agent-rules.js +96 -17
  55. package/dist/init/astro-patch.d.ts +2 -1
  56. package/dist/init/astro-patch.js +11 -5
  57. package/dist/init/capabilities.d.ts +22 -0
  58. package/dist/init/capabilities.js +76 -0
  59. package/dist/init/confirm.d.ts +96 -0
  60. package/dist/init/confirm.js +138 -0
  61. package/dist/init/cra.js +3 -2
  62. package/dist/init/detect.d.ts +8 -0
  63. package/dist/init/detect.js +15 -0
  64. package/dist/init/non-js-project.d.ts +27 -0
  65. package/dist/init/non-js-project.js +84 -0
  66. package/dist/init/plan-framework.d.ts +9 -0
  67. package/dist/init/plan-framework.js +72 -16
  68. package/dist/init/plan.d.ts +40 -4
  69. package/dist/init/plan.js +183 -43
  70. package/dist/init/run.d.ts +24 -2
  71. package/dist/init/run.js +181 -46
  72. package/dist/init/slash-command.d.ts +1 -1
  73. package/dist/init/slash-command.js +46 -6
  74. package/dist/init/snippets.d.ts +64 -5
  75. package/dist/init/snippets.js +220 -31
  76. package/dist/init/vite-config.js +29 -3
  77. package/dist/input/network-mock-tools.d.ts +19 -0
  78. package/dist/input/network-mock-tools.js +29 -4
  79. package/dist/license/license.js +1 -1
  80. package/dist/mcp/mcp-proxy.d.ts +69 -3
  81. package/dist/mcp/mcp-proxy.js +215 -23
  82. package/dist/mcp/mcp.js +58 -7
  83. package/dist/mcp/proxy-lifecycle.d.ts +20 -1
  84. package/dist/mcp/proxy-lifecycle.js +22 -1
  85. package/dist/pool/browser-pool.d.ts +22 -2
  86. package/dist/pool/browser-pool.js +44 -4
  87. package/dist/pool/playwright-launcher.js +10 -2
  88. package/dist/project/learned-routes.d.ts +24 -0
  89. package/dist/project/learned-routes.js +63 -0
  90. package/dist/project/project-store.d.ts +6 -0
  91. package/dist/project/project-store.js +25 -1
  92. package/dist/session/live-control-tools.js +39 -2
  93. package/dist/session/live-control.d.ts +7 -9
  94. package/dist/session/live-control.js +34 -1
  95. package/dist/session/no-session-diagnosis.d.ts +23 -4
  96. package/dist/session/no-session-diagnosis.js +128 -44
  97. package/dist/session/no-session-watch.js +12 -0
  98. package/dist/session/ref-provenance.d.ts +54 -0
  99. package/dist/session/ref-provenance.js +61 -0
  100. package/dist/session/review-store.d.ts +19 -0
  101. package/dist/session/review-store.js +34 -5
  102. package/dist/session/session-lease.js +1 -1
  103. package/dist/session/session-manager.d.ts +9 -0
  104. package/dist/session/session-manager.js +11 -0
  105. package/dist/session/session.d.ts +8 -0
  106. package/dist/session/session.js +12 -0
  107. package/dist/status-payload.d.ts +13 -1
  108. package/dist/status-payload.js +6 -1
  109. package/dist/telemetry/app-instrumented.d.ts +37 -0
  110. package/dist/telemetry/app-instrumented.js +54 -0
  111. package/dist/telemetry/automation-hint.d.ts +17 -0
  112. package/dist/telemetry/automation-hint.js +71 -0
  113. package/dist/telemetry/bug-found.d.ts +0 -20
  114. package/dist/telemetry/bug-found.js +38 -21
  115. package/dist/telemetry/cli-telemetry.js +7 -1
  116. package/dist/telemetry/daemon-telemetry.d.ts +4 -4
  117. package/dist/telemetry/daemon-telemetry.js +8 -4
  118. package/dist/telemetry/feedback.js +1 -1
  119. package/dist/telemetry/init-telemetry.js +9 -1
  120. package/dist/telemetry/install-source.d.ts +36 -0
  121. package/dist/telemetry/install-source.js +40 -0
  122. package/dist/telemetry/run-telemetry.js +4 -1
  123. package/dist/telemetry/session-metrics.d.ts +3 -3
  124. package/dist/telemetry/session-metrics.js +9 -4
  125. package/dist/telemetry/telemetry.d.ts +7 -1
  126. package/dist/telemetry/telemetry.js +17 -2
  127. package/dist/telemetry/tool-refused.d.ts +19 -0
  128. package/dist/telemetry/tool-refused.js +55 -0
  129. package/dist/telemetry/verification-of.js +72 -8
  130. package/dist/temp-dir.d.ts +27 -0
  131. package/dist/temp-dir.js +46 -0
  132. package/dist/tools/act-tools.js +77 -11
  133. package/dist/tools/assert-grade.d.ts +1 -1
  134. package/dist/tools/assert-grade.js +27 -5
  135. package/dist/tools/assert-verdict.js +25 -1
  136. package/dist/tools/contract-tools.js +4 -0
  137. package/dist/tools/dynamic-tools.js +1 -1
  138. package/dist/tools/error-recovery.d.ts +10 -0
  139. package/dist/tools/error-recovery.js +70 -5
  140. package/dist/tools/feedback-invite.js +1 -1
  141. package/dist/tools/invoke-tool.js +149 -32
  142. package/dist/tools/lease-tools.d.ts +29 -1
  143. package/dist/tools/lease-tools.js +58 -5
  144. package/dist/tools/navigate-result.js +1 -1
  145. package/dist/tools/observe-tools.js +1 -1
  146. package/dist/tools/observed-nothing.js +1 -1
  147. package/dist/tools/react-grace.d.ts +48 -0
  148. package/dist/tools/react-grace.js +87 -0
  149. package/dist/tools/read-tools.js +20 -4
  150. package/dist/tools/settle-in-flight.d.ts +79 -0
  151. package/dist/tools/settle-in-flight.js +106 -0
  152. package/dist/tools/snapshot-delta.d.ts +7 -0
  153. package/dist/tools/snapshot-delta.js +130 -6
  154. package/dist/tools/tool-kit.d.ts +52 -5
  155. package/dist/tools/tool-kit.js +55 -24
  156. package/dist/tools/tool-surface.js +28 -9
  157. package/dist/tools/tools.js +38 -4
  158. package/dist/visual/capture-cleanup.d.ts +16 -0
  159. package/dist/visual/capture-cleanup.js +39 -0
  160. package/dist/visual/visual-tools.d.ts +18 -0
  161. package/dist/visual/visual-tools.js +24 -5
  162. package/docs/README.md +56 -11
  163. package/docs/actions.mdx +87 -0
  164. package/docs/agent-cheatsheet.md +102 -26
  165. package/docs/architecture.md +46 -35
  166. package/docs/benchmarks.md +76 -72
  167. package/docs/best-practices.mdx +211 -0
  168. package/docs/capabilities.mdx +387 -0
  169. package/docs/cli/affected.mdx +51 -0
  170. package/docs/cli/capsules.mdx +69 -0
  171. package/docs/cli/cloud.mdx +127 -0
  172. package/docs/cli/doctor.mdx +76 -0
  173. package/docs/cli/drive.mdx +55 -0
  174. package/docs/cli/feedback.mdx +67 -0
  175. package/docs/cli/gate.mdx +64 -0
  176. package/docs/cli/help.mdx +92 -0
  177. package/docs/cli/hunt.mdx +57 -0
  178. package/docs/cli/identify.mdx +62 -0
  179. package/docs/cli/init.mdx +94 -0
  180. package/docs/cli/kill.mdx +91 -0
  181. package/docs/cli/license.mdx +48 -0
  182. package/docs/cli/mcp.mdx +61 -0
  183. package/docs/cli/open.mdx +68 -0
  184. package/docs/cli/restart.mdx +60 -0
  185. package/docs/cli/rollback.mdx +35 -0
  186. package/docs/cli/serve.mdx +62 -0
  187. package/docs/cli/status.mdx +64 -0
  188. package/docs/cli/stop.mdx +56 -0
  189. package/docs/cli/telemetry.mdx +57 -0
  190. package/docs/cli/update.mdx +52 -0
  191. package/docs/cli/verify.mdx +57 -0
  192. package/docs/cli/version.mdx +39 -0
  193. package/docs/cli/watch.mdx +55 -0
  194. package/docs/cli.mdx +219 -0
  195. package/docs/debugging.md +13 -9
  196. package/docs/deploy-checks.md +15 -11
  197. package/docs/desktop-apps.md +49 -40
  198. package/docs/desktop.mdx +188 -0
  199. package/docs/docs.json +363 -0
  200. package/docs/enterprise.md +20 -16
  201. package/docs/faq.mdx +88 -0
  202. package/docs/fixtures.md +11 -7
  203. package/docs/flows.md +26 -22
  204. package/docs/for-agents.md +71 -0
  205. package/docs/frameworks.mdx +199 -0
  206. package/docs/gate-plan.md +45 -41
  207. package/docs/gates.md +23 -19
  208. package/docs/getting-started.md +76 -65
  209. package/docs/human-control.md +22 -18
  210. package/docs/index.mdx +101 -0
  211. package/docs/install-agentic.mdx +215 -0
  212. package/docs/install-manual.mdx +194 -0
  213. package/docs/instrumentation.mdx +232 -0
  214. package/docs/integration-patterns.md +38 -34
  215. package/docs/local-registry.md +16 -12
  216. package/docs/multi-agent-testing.md +16 -12
  217. package/docs/packages/babel-plugin.mdx +70 -0
  218. package/docs/packages/browser.mdx +111 -0
  219. package/docs/packages/core.mdx +106 -0
  220. package/docs/packages/electron.mdx +87 -0
  221. package/docs/packages/eslint-plugin.mdx +84 -0
  222. package/docs/packages/next.mdx +91 -0
  223. package/docs/packages/react.mdx +91 -0
  224. package/docs/packages/server.mdx +98 -0
  225. package/docs/packages/tauri.mdx +63 -0
  226. package/docs/packages/test.mdx +108 -0
  227. package/docs/packages/vite-plugin.mdx +96 -0
  228. package/docs/packages.mdx +217 -0
  229. package/docs/platform-integration.md +33 -26
  230. package/docs/predicates.mdx +179 -0
  231. package/docs/quickstart.mdx +211 -0
  232. package/docs/recipes.mdx +242 -0
  233. package/docs/skill-file.mdx +105 -0
  234. package/docs/state-management.mdx +197 -0
  235. package/docs/style.css +14 -0
  236. package/docs/system-map.md +47 -32
  237. package/docs/telemetry-contract.md +101 -46
  238. package/docs/telemetry-events.mdx +430 -0
  239. package/docs/telemetry.md +63 -50
  240. package/docs/testing.md +10 -6
  241. package/docs/token-efficiency.md +30 -26
  242. package/docs/tools-act-and-wait.mdx +123 -0
  243. package/docs/tools-act-sequence.mdx +120 -0
  244. package/docs/tools-act.mdx +82 -0
  245. package/docs/tools-assert.mdx +70 -0
  246. package/docs/tools-console.mdx +59 -0
  247. package/docs/tools-inspect.mdx +85 -0
  248. package/docs/tools-navigate.mdx +63 -0
  249. package/docs/tools-network.mdx +86 -0
  250. package/docs/tools-observe.mdx +89 -0
  251. package/docs/tools-overview.mdx +157 -0
  252. package/docs/tools-query.mdx +82 -0
  253. package/docs/tools-session-and-feedback.mdx +91 -0
  254. package/docs/tools-sessions.mdx +65 -0
  255. package/docs/tools-snapshot.mdx +75 -0
  256. package/docs/tools-state.mdx +81 -0
  257. package/docs/tools-tools-and-run.mdx +125 -0
  258. package/docs/tools-wait-for.mdx +66 -0
  259. package/docs/troubleshooting.mdx +193 -0
  260. package/docs/usage.md +175 -153
  261. package/docs/vs-chrome-devtools-mcp.mdx +82 -0
  262. package/docs/vs-playwright-mcp.mdx +85 -0
  263. package/docs/vs-screenshots.mdx +96 -0
  264. package/docs/what-is-reticle.mdx +63 -0
  265. package/docs/why-reticle.mdx +107 -0
  266. package/package.json +2 -2
  267. package/docs/matrix/2.5.0/claude-code-machine.json +0 -19
  268. package/docs/matrix/2.5.0/codex-machine.json +0 -20
  269. package/docs/matrix/2.5.0/cursor-machine.json +0 -22
  270. package/docs/matrix/2.5.0/gemini-machine.json +0 -22
  271. package/docs/matrix/2.5.0/opencode-machine.json +0 -22
  272. package/docs/matrix/2.5.0/vscode-machine.json +0 -22
  273. package/docs/matrix/2.5.0/windsurf-machine.json +0 -22
  274. package/docs/matrix/MATRIX.md +0 -26
  275. package/docs/matrix/README.md +0 -59
  276. package/docs/mint.json +0 -61
@@ -12,7 +12,7 @@
12
12
  * Extracted because a rule this easy to get wrong, feeding the one number shown to investors, should
13
13
  * be readable and testable on its own rather than inferred from a ternary inside a dispatcher.
14
14
  */
15
- import { VerifiedReason } from '@reticlehq/core';
15
+ import { CaptureLoss, VerifiedReason } from '@reticlehq/core';
16
16
  import { VERIFICATION_TOOLS } from '../tools/feedback-tools.js';
17
17
  import { getBrowserMode } from './browser-mode.js';
18
18
  /**
@@ -27,11 +27,64 @@ function reasonOf(result) {
27
27
  const raw = result['verifiedReason'];
28
28
  return 'string' === typeof raw && REASONS.has(raw) ? raw : undefined;
29
29
  }
30
+ /**
31
+ * WHICH loss made the capture unclean, read off the honesty block the verdict already carries.
32
+ *
33
+ * Narrowed against core's own list for the same reason `reasonOf` is: a value that is not a member
34
+ * is not an answer. Reported as `OTHER` rather than dropped when the block says the capture was
35
+ * dirty and names no kind — the loss is real either way, and a silent absence there would read as
36
+ * "no unclean verdicts happened", which is the opposite of the truth.
37
+ */
38
+ const LOSSES = new Set(Object.values(CaptureLoss));
39
+ function uncleanLossOf(result, reason) {
40
+ if (VerifiedReason.UNCLEAN_CAPTURE !== reason)
41
+ return undefined;
42
+ const honesty = result['honesty'];
43
+ const integrity = null !== honesty && 'object' === typeof honesty
44
+ ? honesty.integrity
45
+ : undefined;
46
+ const first = Array.isArray(integrity?.losses) ? integrity.losses[0] : undefined;
47
+ return 'string' === typeof first && LOSSES.has(first)
48
+ ? first
49
+ : CaptureLoss.OTHER;
50
+ }
30
51
  /** Suite statuses. `unverifiable` is a suite that ran but proved nothing — including an empty one. */
31
52
  const SuiteStatus = {
32
53
  PASS: 'pass',
33
54
  FAIL: 'fail',
34
55
  };
56
+ /**
57
+ * Did the assertion itself pass, across three tool shapes that spell it three ways.
58
+ *
59
+ * `reticle_assert` spreads its verdict at the top level, so `pass` is right there. `flow_verify`
60
+ * reports a suite `status`. **`reticle_act_and_wait` nests it under `verdict`**, and that omission is
61
+ * why `falseGreenCaught` had never once been true in the field: `passed` came back `undefined` for
62
+ * every act_and_wait, so `true === passed` was false whatever the app did, on the one tool the
63
+ * product tells agents to reach for first.
64
+ *
65
+ * The metric it feeds is the product's central claim reduced to a boolean, and it was structurally
66
+ * unable to fire on the flagship path. Worse, the test file pinned that: one case used the real
67
+ * nested shape and asserted `falseGreenCaught: false`, directly beneath a comment calling this case
68
+ * "the thesis", while the case that asserted `true` used a flat shape act_and_wait never emits.
69
+ *
70
+ * A permanently-zero metric is indistinguishable from a working one that has nothing to report, so
71
+ * nobody could have noticed this from the data. It took reading the emit path.
72
+ */
73
+ function passedOf(result, status) {
74
+ if ('boolean' === typeof result['pass'])
75
+ return result['pass'];
76
+ const verdict = result['verdict'];
77
+ if (null !== verdict && 'object' === typeof verdict) {
78
+ const nested = verdict['pass'];
79
+ if ('boolean' === typeof nested)
80
+ return nested;
81
+ }
82
+ if (status === SuiteStatus.PASS)
83
+ return true;
84
+ if (status === SuiteStatus.FAIL)
85
+ return false;
86
+ return undefined;
87
+ }
35
88
  /**
36
89
  * The verification payload for a result, or undefined when nothing was verified.
37
90
  *
@@ -52,20 +105,28 @@ export function verificationOf(toolName, result, durationMs, brand) {
52
105
  // metric's convenience — but a status that is neither pass nor fail is NOT a verdict, and an empty
53
106
  // suite reports exactly that.
54
107
  const status = result['status'];
55
- const passed = 'boolean' === typeof result['pass']
56
- ? result['pass']
57
- : status === SuiteStatus.PASS
58
- ? true
59
- : status === SuiteStatus.FAIL
60
- ? false
61
- : undefined;
108
+ const passed = passedOf(result, status);
62
109
  if (verified === undefined && passed === undefined)
63
110
  return undefined;
64
111
  const reason = reasonOf(result);
112
+ const uncleanLoss = uncleanLossOf(result, reason);
65
113
  return {
66
114
  via: toolName,
67
115
  verified: verified ?? (true === passed ? 'yes' : 'no'),
68
116
  passed: passed ?? 'yes' === verified,
117
+ // The assertion passed and Reticle answered `no` anyway: a green actively refuted.
118
+ //
119
+ // Deliberately NOT widened to every refusal of a passing assertion, though several of them are
120
+ // arguably false greens caught too. `decideVerified` refuses a green seven ways and only
121
+ // `contradicted` answers `no`; the rest answer `unknown` on purpose, because UNKNOWN must never
122
+ // collapse into NO. `already_true` is the tempting one: a green that proved nothing because the
123
+ // condition already held before the action, found by a check built for exactly that.
124
+ //
125
+ // It stays out because `reason` is on this same payload. Anyone who wants the wider number can
126
+ // count it without a definition change, and a definition that shifts underneath a metric is
127
+ // worse than one that is narrow, because the old and new series look comparable and are not.
128
+ // Widening it would also have landed in the same release as the fix above, making it impossible
129
+ // to tell how much of any rise was the bug and how much was the redefinition.
69
130
  falseGreenCaught: true === passed && 'no' === verified,
70
131
  durationMs,
71
132
  // Headless CI, a human watching, or somebody's own dev server — three different products behind
@@ -80,5 +141,8 @@ export function verificationOf(toolName, result, durationMs, brand) {
80
141
  // belonging to three different owners — the agent, the app, and Reticle's own blind spots — and
81
142
  // they need opposite responses. A suite verdict has no clause behind it and reports none.
82
143
  ...(reason === undefined ? {} : { reason }),
144
+ // WHAT was lost, when the reason was that something was. Three owners, three fixes, one bar
145
+ // until now.
146
+ ...(uncleanLoss === undefined ? {} : { uncleanLoss }),
83
147
  };
84
148
  }
@@ -0,0 +1,27 @@
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
+ /**
21
+ * Delete a temp directory tree, tolerating Windows' delayed handle release.
22
+ *
23
+ * Never throws: a temp directory that outlives the test is the operating system's problem, not a
24
+ * reason to fail a run or to report a defect the product does not have. What must not happen is the
25
+ * opposite — a red that says nothing about the code under test.
26
+ */
27
+ export declare function removeTempDir(dir: string): Promise<void>;
@@ -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
+ }
@@ -6,7 +6,7 @@
6
6
  import { z } from 'zod';
7
7
  import { aliasParam } from './alias-args.js';
8
8
  import { captureAct, compileSequenceStep } from '../flows/replay.js';
9
- import { ActionType, ActionWarning, DEFAULT_ASSERT_TIMEOUT_MS, InputMode, ReticleCommand, Verified, PredicateKind, } from '@reticlehq/core';
9
+ import { ActionType, ActionWarning, CaptureLoss, DEFAULT_ASSERT_TIMEOUT_MS, InputMode, ReticleCommand, Verified, PredicateKind, } from '@reticlehq/core';
10
10
  import { assertNativeInputSupported } from './act-danger.js';
11
11
  import { leanActResult, mutatedWithin } from './act-view.js';
12
12
  import { ReticleTool } from './tool-names.js';
@@ -14,8 +14,11 @@ import { buildReactionReport, summarizeReaction } from '../events/reaction.js';
14
14
  import { parsePredicate } from '../events/predicate-parse.js';
15
15
  import { causalSummary } from '../capsule/causal-summary.js';
16
16
  import { findContradictions } from '../events/contradictions.js';
17
+ import { inFlightRequestLabels, waitForInFlight } from './settle-in-flight.js';
18
+ import { waitForReaction } from './react-grace.js';
17
19
  import { decideVerified } from '../honesty/verified.js';
18
20
  import { readsDomState } from '../honesty/already-true.js';
21
+ import { describeWaitTarget } from '../honesty/unsettled.js';
19
22
  import { saveFailedAssertCapsule } from './act-capsule.js';
20
23
  import { buildDivergenceCapsule } from '../capsule/capsule.js';
21
24
  import { predicateToExpectedLinks } from '../capsule/predicate-to-links.js';
@@ -55,7 +58,7 @@ export const ACT_TOOLS = [
55
58
  {
56
59
  name: ReticleTool.ACT,
57
60
  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`): docs/usage.md §18.',
61
+ 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.',
59
62
  inputSchema: {
60
63
  ref: z
61
64
  .string()
@@ -64,7 +67,7 @@ export const ACT_TOOLS = [
64
67
  args: z
65
68
  .record(z.unknown())
66
69
  .optional()
67
- .describe('Action-specific arguments: { value } for fill/select, { text } for type/press, { 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.'),
70
+ .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.'),
68
71
  refuseWhenThrottled: z
69
72
  .boolean()
70
73
  .optional()
@@ -255,7 +258,7 @@ export const ACT_TOOLS = [
255
258
  args: z
256
259
  .record(z.unknown())
257
260
  .optional()
258
- .describe('Action-specific arguments: { value } for fill/select, { text } for type/press, { confirmDangerous: true } for a potentially destructive control.'),
261
+ .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.'),
259
262
  predicate: PredicateSchema.optional().describe('Alias for `until` (the name reticle_assert / reticle_wait_for use).'),
260
263
  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
264
  timeout_ms: z
@@ -295,7 +298,7 @@ export const ACT_TOOLS = [
295
298
  .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
299
  summary: z
297
300
  .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.'),
301
+ .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. 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.'),
299
302
  // Promoted out of `effect` on RED only — the file:line the failure came from, the first thing a
300
303
  // repair wants. Undeclared, it was stripped on the validating profile exactly like the structured
301
304
  // cause above was, losing the highest-value pointer on the one path (a failed verdict) that has it.
@@ -355,11 +358,6 @@ export const ACT_TOOLS = [
355
358
  // argument and ignoring it told the agent its trusted click had happened. See act-danger.
356
359
  assertNativeInputSupported(asRecord(args['args']));
357
360
  const since = session.elapsed();
358
- // `dropped` is cumulative for the SESSION, so comparing against a pre-action reading is what
359
- // asks "did the buffer lose anything while THIS action was observed?" — the question the
360
- // honesty block means. Reading the raw total pinned every later verdict to `unknown` after a
361
- // single early eviction (measured on the Next.js demo at dropped:51, windows intact).
362
- const droppedBefore = session.bufferHealth().dropped;
363
361
  // The cursor + effect are marked after the act dispatches (below) — a refused act leaves none.
364
362
  // The attribution window stays open across the settle wait below, so post-dispatch async events
365
363
  // (the whole point of act_and_wait) attribute to this action. finishAction fires after the wait.
@@ -385,9 +383,39 @@ export const ACT_TOOLS = [
385
383
  // stops the spread below from inheriting an earlier reticle_act's action and mutation count.
386
384
  session.lastAct.markActed(since, asString(args['action']), mutatedWithin(asRecord(actResult.result)));
387
385
  // Honesty: floor the predicate at this act's cursor so a stale buffered event can't satisfy it.
386
+ const predicateStarted = session.elapsed();
388
387
  const verdict = timeout > 0
389
388
  ? await waitForPredicate(session, until, timeout, since)
390
389
  : await evaluatePredicate(session, until, since);
390
+ // The predicate resolves the INSTANT it holds, which on an optimistically-navigating app is
391
+ // while the write is still in flight — so the verdict was taken over a window the app had not
392
+ // finished, and came back `unknown / unsettled` asking the CALLER to re-check. A login that
393
+ // genuinely succeeded returned at 492ms of an 8000ms budget with every corroborating channel
394
+ // agreeing. Spend the rest of the budget the caller already granted.
395
+ //
396
+ // This decides nothing: it waits for the answer instead of guessing at it. A response that
397
+ // arrives as a FAILURE still contradicts — and now arrives INSIDE the window, where the
398
+ // detector can see it at all — while a request still open when the budget genuinely runs out
399
+ // reports unsettled exactly as before, an honest limit rather than an early exit. Costs
400
+ // nothing on the common path, where no request is in flight.
401
+ if (verdict.pass && timeout > 0) {
402
+ const sleep = {
403
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
404
+ };
405
+ const spent = () => timeout - (session.elapsed() - predicateStarted);
406
+ await waitForInFlight(session, since, spent(), sleep);
407
+ // Waiting for the response moved the hazard rather than removing it: the response now lands
408
+ // inside the window BY DESIGN, and the app's re-render happens a task or two later. Close
409
+ // the window in that gap and every channel agrees the app took a successful write and did
410
+ // nothing — `response-ignored`, i.e. `verified:"no"` on a correct app, produced entirely by
411
+ // where we stopped looking. A false accusation is the more damaging direction of error for
412
+ // a verification tool: it sends someone to fix code that is not broken.
413
+ //
414
+ // So the response is not the end of the window; the app's REACTION to it is. Paid only in
415
+ // the shape that would otherwise be accused — a successful mutating write with nothing
416
+ // moved after it — and short enough that a genuinely dropped response is still reported.
417
+ await waitForReaction(session, since, spent(), sleep);
418
+ }
391
419
  const r = asRecord(actResult.result);
392
420
  if ('boolean' === typeof r['settled'])
393
421
  settledOutcome = r['settled'];
@@ -427,12 +455,28 @@ export const ACT_TOOLS = [
427
455
  // whose own words were "over a clean capture".
428
456
  const gapNote = transportGapNote(windowEvents);
429
457
  const impeachingNotes = [impeaching.note, gapNote].filter((n) => n !== undefined);
458
+ const bufferLost = session.lostSince(since);
459
+ // Which loss, as an enum, beside the prose that describes it. Classified here because this is
460
+ // the only place that knows the three apart: our buffer, our transport, and the page's own
461
+ // boundaries. See `CaptureLoss`.
462
+ const losses = [
463
+ ...(bufferLost ? [CaptureLoss.BUFFER_LOSS] : []),
464
+ ...(gapNote === undefined ? [] : [CaptureLoss.TRANSPORT_GAP]),
465
+ ...(impeaching.note === undefined ? [] : [CaptureLoss.BLIND_SPOT]),
466
+ ];
430
467
  const honesty = buildHonestyBlock({
431
468
  grade: gradeOf(gradedLinks),
432
469
  attribution: 'window',
433
- truncated: session.bufferHealth().dropped > droppedBefore,
470
+ // Did the buffer lose scarce evidence FROM THIS WINDOW — not "did it evict anything while
471
+ // the action ran", which was the previous rule and which is true on essentially every live
472
+ // page. Age eviction retires everything past 60s on every push, and the churn floor is
473
+ // sacrificed on purpose; neither costs this verdict a single event it needed. In the
474
+ // field this made `unclean_capture` the dominant cause of `unknown`, reported for
475
+ // evictions that happened outside the window they impeached.
476
+ truncated: bufferLost,
434
477
  coveragePartial: Coverage.PARTIAL === coverage.coverage,
435
478
  ...(0 === impeachingNotes.length ? {} : { blindSpots: impeachingNotes }),
479
+ losses,
436
480
  });
437
481
  const capsuleSaved = await saveFailedAssertCapsule({
438
482
  deps,
@@ -446,8 +490,23 @@ export const ACT_TOOLS = [
446
490
  // Pass the action: an EMPTY window then reads as "the target does not react" rather than as a
447
491
  // clean run — `settled` is satisfied by a page that did nothing, i.e. by a click that missed.
448
492
  const acted = asString(args['action']);
493
+ // `prior` matters here for the same reason it matters on the assert path: a SCALE error
494
+ // disagrees with a value the API stated EARLIER in the session, which an action-scoped
495
+ // window can never contain. Without it `unit-mismatch` — the money false green, where a
496
+ // total renders 100x off — is structurally unreachable from `act_and_wait`, which is the
497
+ // tool the product tells agents to reach for first. Our best detector could not fire on our
498
+ // flagship path, and nothing said so: the finding was simply never produced.
499
+ //
500
+ // Learning material only. Attribution is unchanged — findings still come exclusively from
501
+ // `windowEvents`, exactly as on the assert path.
502
+ const prior = since > 0 ? (await session.queryEvents({ since: 0 })).filter((e) => e.t < since) : [];
503
+ //
504
+ // `actionSince` is the window's own floor here, which is the tightest attribution there is —
505
+ // and it is what lets `duplicate-request` claim "one user action was performed" and mean it.
449
506
  const contradictions = findContradictions(windowEvents, {
450
507
  action: acted,
508
+ prior,
509
+ actionSince: since,
451
510
  ...session.lastAct.effect(),
452
511
  });
453
512
  // The single field an agent reads. Everything below it is the evidence it was derived from;
@@ -470,6 +529,13 @@ export const ACT_TOOLS = [
470
529
  // `settled` is genuinely optional: a wait that declared no predicate never measured it, and
471
530
  // passing `false` there would report "never settled" about something never asked to settle.
472
531
  ...(settledOutcome === undefined ? {} : { settled: settledOutcome }),
532
+ // What the wait was for and what was still outstanding when it ended. Read only by the
533
+ // UNSETTLED clauses; supplied always because both of them are reachable from here and the
534
+ // window is already in hand.
535
+ unsettled: {
536
+ waitedFor: describeWaitTarget(until),
537
+ stillInFlight: inFlightRequestLabels(windowEvents),
538
+ },
473
539
  });
474
540
  return withControl(session, {
475
541
  ...decision,
@@ -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,9 +1,12 @@
1
+ import { CaptureLoss } from '@reticlehq/core';
1
2
  import { findContradictions } from '../events/contradictions.js';
2
3
  import { blindSpotsFromState, buildCoverageStatement, Coverage, impeachesCapture, transportGapNote, } from '../honesty/blind-spots.js';
3
4
  import { buildHonestyBlock } from '../honesty/honesty.js';
4
5
  import { hasAcceptedWrite } from '../honesty/accepted-write.js';
5
6
  import { hasUnreadWriteOutcome } from '../honesty/unread-outcome.js';
6
7
  import { decideVerified } from '../honesty/verified.js';
8
+ import { describeWaitTarget } from '../honesty/unsettled.js';
9
+ import { inFlightRequestLabels } from './settle-in-flight.js';
7
10
  import { gradeOfPredicate } from './assert-grade.js';
8
11
  /**
9
12
  * The honesty verdict for a plain `reticle_assert`.
@@ -48,7 +51,16 @@ observationLost) {
48
51
  // measured live, `observe` over a wide window reported `unit-mismatch` while `assert` on the same
49
52
  // session reported none. Attribution is unchanged: findings still come only from windowEvents.
50
53
  const prior = since > 0 ? (await session.queryEvents({ since: 0 })).filter((e) => e.t < since) : [];
51
- const contradictions = findContradictions(windowEvents, { prior });
54
+ // The last act, when it falls inside the window being judged. Without it `duplicate-request` counted
55
+ // `method + url` across whatever window the caller asked for, so two legitimate separate saves to one
56
+ // endpoint were reported as a double submit — the instrument accusing the app of something nobody
57
+ // could show it did. An assert taken over a window that predates the act attributes nothing, and the
58
+ // rule then stays quiet.
59
+ const actCursor = session.lastAct.cursor();
60
+ const contradictions = findContradictions(windowEvents, {
61
+ prior,
62
+ ...(actCursor !== undefined && actCursor >= since ? { actionSince: actCursor } : {}),
63
+ });
52
64
  // Only a spot that IMPEACHES the capture downgrades a verdict. A structural boundary (virtualized
53
65
  // rows, a cross-origin frame) is reported as coverage and must not impugn what WAS observed.
54
66
  const impeaching = buildCoverageStatement(spots.filter((sp) => impeachesCapture(sp.kind)));
@@ -68,10 +80,22 @@ observationLost) {
68
80
  attribution: 'window',
69
81
  coveragePartial: Coverage.PARTIAL === statement.coverage,
70
82
  ...(0 === impeachingNotes.length ? {} : { blindSpots: impeachingNotes }),
83
+ // Which loss, as an enum, beside the prose. `assert` observes an already-open window and never
84
+ // consults the ring buffer's health, so `buffer_loss` is not one of its answers.
85
+ losses: [
86
+ ...(gap === undefined ? [] : [CaptureLoss.TRANSPORT_GAP]),
87
+ ...(impeaching.note === undefined ? [] : [CaptureLoss.BLIND_SPOT]),
88
+ ],
71
89
  }),
72
90
  contradictions,
73
91
  ...(outcomePending ? { outcomePending } : {}),
74
92
  ...(outcomeUnread ? { outcomeUnread } : {}),
93
+ // Same detail the act path supplies: this route reaches UNSETTLED through an absence-derived
94
+ // contradiction, and "the window closed before the app finished" is no more actionable here.
95
+ unsettled: {
96
+ waitedFor: describeWaitTarget(predicate),
97
+ stillInFlight: inFlightRequestLabels(windowEvents),
98
+ },
75
99
  });
76
100
  return { decision: decision, contradictions, coverage };
77
101
  }
@@ -38,6 +38,10 @@ export const CONTRACT_TOOLS = [
38
38
  {
39
39
  name: ReticleTool.CAPABILITIES,
40
40
  description: 'The app-advertised testable surface (reticle.describe): testids, signals, stores, and named flows. Call this first to learn what to assert on without reading source. Pass { fromDisk:true } to read the git-checked .reticle/contract.json instead of the live session (a fresh agent can learn the surface with no browser attached).',
41
+ // Every core tool carries one, so an agent reading the surface never has to guess the shape.
42
+ // `fromDisk` is the example deliberately: it is the call that works with nothing attached, which
43
+ // is the state a fresh agent is actually in when it first reads this list.
44
+ example: { [FROM_DISK_ARG]: true },
41
45
  inputSchema: { [FROM_DISK_ARG]: z.boolean().optional(), ...sessionIdShape },
42
46
  outputSchema: {
43
47
  testids: z.array(z.string()),
@@ -69,7 +69,7 @@ export function buildDynamicTools(allTools, profile) {
69
69
  // `full` advertises all 46 tools DIRECTLY and carries no meta-tools at all, so
70
70
  // reticle_run does not exist there. Saying otherwise sends the agent to a tool the
71
71
  // profile does not have.
72
- note: `The profile is read once at daemon startup — change ${TOOL_PROFILE_ENV} and restart the daemon, or it has no effect. Under this profile every unadvertised tool is reachable through reticle_run; under \`full\` there are no meta-tools because every tool is advertised directly.`,
72
+ note: `The profile is read once at daemon startup: change ${TOOL_PROFILE_ENV} and restart the daemon, or it has no effect. Under this profile every unadvertised tool is reachable through reticle_run; under \`full\` there are no meta-tools because every tool is advertised directly.`,
73
73
  },
74
74
  };
75
75
  const reticleTools = {
@@ -8,6 +8,7 @@
8
8
  * on stable, human-authored substrings of the thrown messages (see session-manager.ts, the flow/
9
9
  * baseline stores). No clock, no IO — unit-testable in isolation.
10
10
  */
11
+ import { RefusalReason } from '@reticlehq/core';
11
12
  /** The recovery hints, named so they are not free strings and can be asserted in tests. */
12
13
  export declare const RECOVERY: {
13
14
  readonly NO_SESSION: string;
@@ -40,6 +41,15 @@ export declare const RECOVERY: {
40
41
  * saying a server is running and the recovery saying to go start one.
41
42
  */
42
43
  export declare function recoveryFor(message: string): string | undefined;
44
+ /**
45
+ * WHY a tool refused, as one of the six buckets in core's `RefusalReason`.
46
+ *
47
+ * Reads the SAME rules `buildErrorPayload` reads, in the same order, so the reason we report and the
48
+ * advice the agent got can never describe different things. Anything unrecognised is `other`, which
49
+ * is the bucket that says the classifier has a blind spot — and a growing `other` is a finding about
50
+ * this table rather than about anybody's app.
51
+ */
52
+ export declare function refusalReasonFor(rawMessage: string): RefusalReason;
43
53
  /**
44
54
  * A serialized zod issue array, rendered as a sentence. Anything else is returned untouched.
45
55
  *