@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
@@ -5,8 +5,20 @@ import { selectPath, capDepth } from '../session/state-select.js';
5
5
  import { describeTestidMiss } from './testid-near-miss.js';
6
6
  import { predicateToExpectedLinks } from '../capsule/predicate-to-links.js';
7
7
  import { isAmbient, ambientKeyOf } from '../journal/ambient.js';
8
- import { PredicateSchema, matchValue, evalNet, evalRoute, evalConsole, evalAnimation, evalSignal, evalSettled, } from './predicate-eval.js';
8
+ import { PredicateSchema, matchValue, evalNet, evalRoute, evalConsole, evalAnimation, evalSignal, evalSettled, residualQueryChecks, satisfiesResiduals, describeResidual, } from './predicate-eval.js';
9
9
  export { PredicateSchema, PredicateKind };
10
+ /**
11
+ * A composite result that carries a child's "nobody could evaluate this" up to the verdict rule.
12
+ *
13
+ * `decideVerified` already treats `inconclusive` correctly and does it ahead of the failure clause,
14
+ * so all a composite has to do is stop dropping the field on its way out. `pass` stays false because
15
+ * nothing was proven; what changes is that the verdict reads UNKNOWN rather than blaming the app for
16
+ * a clause the CALL under-specified.
17
+ */
18
+ function unreadableComposite(child, evidence) {
19
+ const reason = child.inconclusive ?? 'a sub-predicate could not be evaluated';
20
+ return { pass: false, failureReason: reason, inconclusive: reason, evidence };
21
+ }
10
22
  async function matchOnce(session, query, state) {
11
23
  const res = await session.command(ReticleCommand.MATCH, { query, state });
12
24
  if (!res.ok)
@@ -14,8 +26,44 @@ async function matchOnce(session, query, state) {
14
26
  return (res.result ?? { matched: false, count: 0, elements: [] });
15
27
  }
16
28
  async function evalElement(session, query, state, absent, diagnose) {
17
- const match = await matchOnce(session, query, state);
29
+ // Fields the browser's locator would have DROPPED, enforced back here — see residualQueryChecks.
30
+ // Checked before the round-trip when nothing can enforce them: a predicate that cannot be evaluated
31
+ // must say so rather than resolve to whatever the surviving half of it happened to match.
32
+ const residual = residualQueryChecks(query);
33
+ if (residual.unusable.length > 0) {
34
+ const reason = `the element locator ignores ${residual.unusable.map((f) => `\`${f}\``).join(', ')} ` +
35
+ `in ${JSON.stringify(query)} — it resolves by the first of by+value, component/source, role, ` +
36
+ 'text, label, placeholder, testid, alt that is present, and nothing here can check the rest. ' +
37
+ 'Assert them one locator at a time, or move the extra field into the locator';
38
+ return { pass: false, failureReason: reason, inconclusive: reason };
39
+ }
40
+ let match = await matchOnce(session, query, state);
18
41
  const subject = JSON.stringify(query);
42
+ // A residual narrows the SET; `count` is every match while `elements` is only the described prefix,
43
+ // so a locator broad enough to be truncated cannot be narrowed honestly. Say so instead of guessing.
44
+ if (residual.checks.length > 0 && match.count > match.elements.length) {
45
+ const reason = `${String(match.count)} elements matched ${subject} and only ${String(match.elements.length)} were described, so ${residual.checks.map(([f]) => `\`${f}\``).join(', ')} could not be checked against all of them — narrow the locator`;
46
+ return { pass: false, failureReason: reason, inconclusive: reason };
47
+ }
48
+ const kept = match.elements.filter((element) => satisfiesResiduals(element, residual.checks));
49
+ // The locator found something and the dropped fields disagree with it. Reported separately from a
50
+ // plain miss because the fixes are opposite: the element IS there, its value is not what was claimed.
51
+ if (residual.checks.length > 0 && match.matched && 0 === kept.length && !absent) {
52
+ const wanted = residual.checks.map(([f, want]) => `${f}=${JSON.stringify(want)}`).join(', ');
53
+ return {
54
+ pass: false,
55
+ failureReason: `element matching ${subject} is present but ${wanted} does not hold`,
56
+ observed: match.elements
57
+ .map((element) => residual.checks.map(([f]) => describeResidual(element, f)).join(', '))
58
+ .join('; '),
59
+ expected: `an element matching ${subject} with ${wanted}`,
60
+ assertion: `element.${residual.checks[0]?.[0] ?? 'residual'}`,
61
+ evidence: match.elements,
62
+ };
63
+ }
64
+ if (residual.checks.length > 0) {
65
+ match = { ...match, matched: kept.length > 0, count: kept.length, elements: kept };
66
+ }
19
67
  // A given-but-missing scope is handled ASYMMETRICALLY, because "absent" and "present" ask different
20
68
  // questions of a scope that no longer exists:
21
69
  // - ABSENT: an element is trivially absent from a container that isn't there. This is also the
@@ -114,6 +162,33 @@ async function evalElement(session, query, state, absent, diagnose) {
114
162
  ...(present.length > 0 ? { evidence: { presentTestids: present } } : {}),
115
163
  };
116
164
  }
165
+ /** The single element of a list, or undefined when there is not exactly one. */
166
+ function oneOf(names) {
167
+ return 1 === names.length ? names[0] : undefined;
168
+ }
169
+ /**
170
+ * `readState`'s truncation report, when the caps actually fired.
171
+ *
172
+ * Its PRESENCE is the warning — the field is omitted entirely on an intact read, which is what lets
173
+ * "the list is short" stay distinguishable from "I shortened the list".
174
+ */
175
+ function truncationOf(result) {
176
+ if ('object' !== typeof result || null === result)
177
+ return undefined;
178
+ const report = result.truncation;
179
+ return 'object' === typeof report && null !== report
180
+ ? report
181
+ : undefined;
182
+ }
183
+ /** A scoped `readState` reply, narrowed to the shape `selectPath` would have produced. */
184
+ function asSelection(result) {
185
+ if ('object' !== typeof result || null === result)
186
+ return undefined;
187
+ const record = result;
188
+ return 'boolean' === typeof record.found
189
+ ? { found: record.found, value: record.value }
190
+ : undefined;
191
+ }
117
192
  async function evalState(session, p) {
118
193
  const res = await session.command(ReticleCommand.STATE_READ, p.store !== undefined ? { store: p.store } : {});
119
194
  if (!res.ok) {
@@ -127,17 +202,75 @@ async function evalState(session, p) {
127
202
  }
128
203
  const stores = (res.result ?? {}).stores ?? {};
129
204
  const names = Object.keys(stores);
130
- const storeName = p.store ?? (1 === names.length ? names[0] : undefined);
205
+ // Ambiguity is about the PATH, not the store count.
206
+ //
207
+ // Registering more than one store is normal — an app store, a query cache, and the render meter
208
+ // Reticle registers itself — and asking "which of these three?" when only one of them HAS the
209
+ // path is a question with one possible answer. It was costing a real verdict: a bench-app drive
210
+ // asserting `{path:'view'}` returned `unknown` while the same response body carried the matching
211
+ // `view` state diff. `unknown` is not a pass, so that is a verification that did not happen.
212
+ //
213
+ // So narrow to the stores that actually carry the path, and refuse only when THOSE collide.
214
+ const candidates = p.store === undefined ? names.filter((n) => selectPath(stores[n], p.path).found) : [];
215
+ const storeName = p.store ?? (1 === names.length ? names[0] : oneOf(candidates));
131
216
  if (storeName === undefined) {
132
- // Neither of these is a finding about the app. With several stores registered the call simply did
133
- // not say which one to read; with none, there is nothing to read at all. No assertion was
134
- // evaluated either way, so this is inconclusive rather than failed — see honesty/inconclusive.
135
- const reason = 0 === names.length
136
- ? 'no registered store to read state from'
137
- : `multiple stores (${names.join(', ')}); name one with \`store\``;
138
- return { pass: false, failureReason: reason, inconclusive: reason };
217
+ // With no store registered there is nothing to read, and with two stores that both carry the
218
+ // path there is no way to pick — neither is a finding about the app, no assertion was evaluated,
219
+ // so both are inconclusive rather than failed. See honesty/inconclusive.
220
+ //
221
+ // Zero candidates is the one case that is NOT a question: every registered store was searched
222
+ // and none exposes the path, so the assertion cannot hold anywhere. That is the same verdict a
223
+ // named store has always produced for a missing path, and it falls through to it below.
224
+ if (0 === names.length) {
225
+ const reason = 'no registered store to read state from';
226
+ return { pass: false, failureReason: reason, inconclusive: reason };
227
+ }
228
+ if (candidates.length > 1) {
229
+ // Names the stores that actually collide. Listing all of them made the reader weigh
230
+ // candidates that could never have matched.
231
+ const reason = `multiple stores (${candidates.join(', ')}) expose '${p.path}'; name one with \`store\``;
232
+ return { pass: false, failureReason: reason, inconclusive: reason };
233
+ }
234
+ return {
235
+ pass: false,
236
+ failureReason: `state path '${p.path}' not found in any registered store (${names.join(', ')})`,
237
+ observed: `no path '${p.path}' in ${names.join(', ')}`,
238
+ expected: `some registered store to expose '${p.path}'`,
239
+ assertion: 'state.path-missing',
240
+ evidence: { searchedStores: names },
241
+ };
242
+ }
243
+ let selection = selectPath(stores[storeName], p.path);
244
+ // The whole-store read walks into a value the transport caps may already have mangled.
245
+ //
246
+ // `readState` has a SCOPED mode that selects the path out of the RAW store before sanitising, added
247
+ // precisely so a large or deep path still resolves. This path was not using it, so a store with one
248
+ // big collection in it truncated the small value sitting beside it, and the comparison then ran
249
+ // against the literal string "[TRUNCATED]" and returned a confident `no`. Measured on the Atlas
250
+ // fixture: a one-element array reported as a failed assertion while the same payload's state diffs
251
+ // showed the assertion holding.
252
+ //
253
+ // Re-read only when a cap actually fired, so an intact read costs exactly what it did before.
254
+ if (truncationOf(res.result) !== undefined) {
255
+ const scoped = await session.command(ReticleCommand.STATE_READ, {
256
+ store: storeName,
257
+ path: p.path,
258
+ });
259
+ const result = scoped.ok ? scoped.result : undefined;
260
+ const scopedTruncation = truncationOf(result);
261
+ if (scopedTruncation !== undefined) {
262
+ // Even the raw sub-tree was too big. Nothing here knows what the value IS, so nothing here can
263
+ // say the assertion failed — that would be an accusation the evidence does not support.
264
+ const reason = `state '${p.path}' could not be read intact — the transport caps truncated it, so the ` +
265
+ 'value was never compared. Assert a narrower path, or a smaller field inside it';
266
+ return { pass: false, failureReason: reason, inconclusive: reason };
267
+ }
268
+ if (result !== undefined) {
269
+ const scopedSelection = asSelection(result);
270
+ if (scopedSelection !== undefined)
271
+ selection = scopedSelection;
272
+ }
139
273
  }
140
- const selection = selectPath(stores[storeName], p.path);
141
274
  if (!selection.found) {
142
275
  return {
143
276
  pass: false,
@@ -206,24 +339,43 @@ diagnose = true) {
206
339
  }
207
340
  case PredicateKind.ALL_OF: {
208
341
  const results = await Promise.all(predicate.predicates.map((p) => evaluatePredicate(session, p, since, diagnose)));
209
- const failed = results.find((r) => !r.pass);
210
- return failed === undefined
211
- ? { pass: true, evidence: results.map((r) => r.evidence) }
212
- : {
342
+ // A clause that genuinely failed OUTRANKS one nobody could read. Softening a real failure to
343
+ // UNKNOWN would hide the defect the agent came for, which is the more expensive of the two
344
+ // mistakes; the reverse — grading an unreadable clause as a defect in the app — is the one
345
+ // that was happening.
346
+ const failed = results.find((r) => !r.pass && r.inconclusive === undefined);
347
+ if (failed !== undefined) {
348
+ return {
213
349
  pass: false,
214
350
  failureReason: failed.failureReason ?? 'a sub-predicate of allOf failed',
215
351
  evidence: results,
216
352
  };
353
+ }
354
+ const unreadable = results.find((r) => r.inconclusive !== undefined);
355
+ if (unreadable !== undefined)
356
+ return unreadableComposite(unreadable, results);
357
+ return { pass: true, evidence: results.map((r) => r.evidence) };
217
358
  }
218
359
  case PredicateKind.ANY_OF: {
219
360
  const results = await Promise.all(predicate.predicates.map((p) => evaluatePredicate(session, p, since, diagnose)));
220
361
  const passed = results.find((r) => r.pass);
221
- return passed !== undefined
222
- ? { pass: true, evidence: passed.evidence }
223
- : { pass: false, failureReason: 'no sub-predicate of anyOf matched', evidence: results };
362
+ if (passed !== undefined)
363
+ return { pass: true, evidence: passed.evidence };
364
+ // "No sub-predicate matched" is a claim anyOf is not entitled to make while one of them was
365
+ // never read: the unreadable clause might have been the one that would have matched.
366
+ const unreadable = results.find((r) => r.inconclusive !== undefined);
367
+ if (unreadable !== undefined)
368
+ return unreadableComposite(unreadable, results);
369
+ return { pass: false, failureReason: 'no sub-predicate of anyOf matched', evidence: results };
224
370
  }
225
371
  case PredicateKind.NOT: {
226
372
  const inner = await evaluatePredicate(session, predicate.predicate, since, diagnose);
373
+ // The sharpest case of the three, and the only one that produced a GREEN. `not` read the
374
+ // child's `pass: false` as "the inner predicate did not hold" and passed — so an assertion
375
+ // nobody could evaluate became a verdict of verified, manufactured out of a missing reading.
376
+ // You cannot negate an answer nobody had.
377
+ if (inner.inconclusive !== undefined)
378
+ return unreadableComposite(inner, inner);
227
379
  return inner.pass
228
380
  ? { pass: false, failureReason: 'negated predicate unexpectedly held', evidence: inner }
229
381
  : { pass: true };
@@ -24,6 +24,14 @@ export declare class RingBuffer {
24
24
  since(cursor: number): ReticleEvent[];
25
25
  /** Events within the last `windowMs`, relative to `now`. */
26
26
  window(windowMs: number, now: number): ReticleEvent[];
27
+ /**
28
+ * Did this buffer evict scarce evidence that belonged to a window opened at `cursor`?
29
+ *
30
+ * The one honest input to "was the capture clean". A `false` here means every non-churn event in
31
+ * the window is still held, whatever the drop counter says — and the drop counter says a great
32
+ * deal, because age eviction runs on every push.
33
+ */
34
+ lostSince(cursor: number): boolean;
27
35
  /** Snapshot of buffer health for the agent — live events held and cumulative drops since connect. */
28
36
  bufferHealth(): {
29
37
  total: number;
@@ -23,6 +23,16 @@ export class RingBuffer {
23
23
  #head = 0;
24
24
  #totalBytes = 0;
25
25
  #droppedCount = 0;
26
+ /**
27
+ * The timestamp of the newest NON-CHURN event this buffer has ever evicted, or `undefined` if it
28
+ * has never lost one. This — not the drop counter — is what impeaches an observation window.
29
+ *
30
+ * The counter cannot answer the question. It moves for age eviction (everything past 60s, on every
31
+ * push) and for churn eviction (the low-signal floor, sacrificed on purpose so scarce evidence
32
+ * survives), neither of which is evidence lost from the window an agent just observed. See
33
+ * `lostSince` and `ring-buffer-window-loss.test.ts`.
34
+ */
35
+ #lastScarceLossT;
26
36
  constructor(options = {}) {
27
37
  this.#maxEvents = options.maxEvents ?? RING_BUFFER_DEFAULTS.MAX_EVENTS;
28
38
  this.#maxAgeMs = options.maxAgeMs ?? RING_BUFFER_DEFAULTS.MAX_AGE_MS;
@@ -81,7 +91,10 @@ export class RingBuffer {
81
91
  }
82
92
  const victim = this.#findChurnAfterHead();
83
93
  if (-1 === victim) {
84
- // Buffer genuinely full of high-signal events — fall back to FIFO so it stays bounded.
94
+ // Buffer genuinely full of high-signal events — fall back to FIFO so it stays bounded. This
95
+ // is the one eviction path that destroys scarce evidence, and the only one that should ever
96
+ // make a verdict say it could not see.
97
+ this.#noteScarceLoss(this.#head);
85
98
  this.#totalBytes -= this.#eventBytes[this.#head] ?? 0;
86
99
  this.#head += 1;
87
100
  continue;
@@ -91,6 +104,7 @@ export class RingBuffer {
91
104
  this.#eventBytes.splice(victim, 1);
92
105
  }
93
106
  while (this.#liveCount() > 0 && (this.#events[this.#head]?.t ?? cutoff) < cutoff) {
107
+ this.#noteScarceLoss(this.#head);
94
108
  this.#totalBytes -= this.#eventBytes[this.#head] ?? 0;
95
109
  this.#head += 1;
96
110
  }
@@ -102,6 +116,25 @@ export class RingBuffer {
102
116
  this.#head = 0;
103
117
  }
104
118
  }
119
+ /** Record an eviction at `index` as scarce loss, unless it was the churn floor being sacrificed. */
120
+ #noteScarceLoss(index) {
121
+ const victim = this.#events[index];
122
+ if (victim === undefined || CHURN_TYPES.has(victim.type))
123
+ return;
124
+ if (this.#lastScarceLossT === undefined || victim.t > this.#lastScarceLossT) {
125
+ this.#lastScarceLossT = victim.t;
126
+ }
127
+ }
128
+ /**
129
+ * Did this buffer evict scarce evidence that belonged to a window opened at `cursor`?
130
+ *
131
+ * The one honest input to "was the capture clean". A `false` here means every non-churn event in
132
+ * the window is still held, whatever the drop counter says — and the drop counter says a great
133
+ * deal, because age eviction runs on every push.
134
+ */
135
+ lostSince(cursor) {
136
+ return this.#lastScarceLossT !== undefined && this.#lastScarceLossT >= cursor;
137
+ }
105
138
  /** First churn event after the head, or -1 if none within the bounded scan (keeps eviction cheap). */
106
139
  #findChurnAfterHead() {
107
140
  const end = Math.min(this.#events.length, this.#head + CHURN_SCAN_LIMIT);
@@ -27,7 +27,7 @@ export const FlowAssertionGrade = {
27
27
  /** Performs actions but asserts nothing observable — passes even if the feature is broken. */
28
28
  ASSERTION_FREE: 'assertion-free',
29
29
  };
30
- const ASSERTION_FREE_WARNING = 'This flow performs actions but asserts no observable consequence — it will pass even if the feature is broken. Add a consequence assertion with reticle_annotate (assert-signal / assert-net) or a success-state.';
30
+ const ASSERTION_FREE_WARNING = 'This flow performs actions but asserts no observable consequence, so it will pass even if the feature is broken. Add a consequence assertion with reticle_annotate (assert-signal / assert-net) or a success-state.';
31
31
  const PRESENCE_ONLY_WARNING = 'This flow only checks element presence, not an observable consequence (signal/network). A locator healed to the wrong element can still pass it. Add a consequence assertion (assert-signal / assert-net / success-state).';
32
32
  const INTENT_WITHOUT_OUTCOME_WARNING = 'This flow declares a business intent but asserts no observable outcome (signal/network) — it claims to verify a goal it cannot actually check. Add a success-state consequence so the flow fails when the goal stops being met.';
33
33
  // Consequence vs presence-only classification lives in @reticlehq/core (one source for all graders).
@@ -367,23 +367,31 @@ export const FLOW_TOOLS = [
367
367
  requested.length > 0) {
368
368
  const concurrency = resolveConcurrency(requested.length, pool.capacity(), parallelArg);
369
369
  const outcomes = await mapWithConcurrency(requested, concurrency, async (flowName) => {
370
- const lease = await acquireLeasedSession(pool, (id) => deps.sessions.get(id) !== undefined, appUrl);
370
+ const start = deps.now();
371
+ const lease = await acquireLeasedSession(pool, deps.sessions, appUrl, projectId);
371
372
  try {
372
- return await replayNamedFlow(deps, { flowName, sessionId: lease.sessionId });
373
+ const replay = await replayNamedFlow(deps, { flowName, sessionId: lease.sessionId });
374
+ return { replay, durationMs: deps.now() - start };
373
375
  }
374
376
  finally {
375
- // Always release: a crashed flow must never leak a slot and starve the rest of the suite.
376
377
  await lease.release().catch(() => undefined);
377
378
  }
378
379
  });
379
380
  const parallelRuns = await Promise.all(outcomes.map(async (o, i) => {
380
381
  const name = requested[i] ?? '';
381
- const replay = o.ok && o.value !== undefined ? o.value : leaseFailureReplay(name, o.error);
382
+ const replay = o.ok && o.value !== undefined ? o.value.replay : leaseFailureReplay(name, o.error);
382
383
  const loaded = await deps.flows.load(name, projectId).catch(() => null);
383
384
  const flow = loaded !== null && loaded.ok ? loaded.value : undefined;
384
385
  return flow === undefined ? { replay } : { replay, flow };
385
386
  }));
387
+ const timed = outcomes.map((o, i) => ({
388
+ replay: o.ok && o.value !== undefined
389
+ ? o.value.replay
390
+ : leaseFailureReplay(requested[i] ?? '', o.error),
391
+ durationMs: o.ok && o.value !== undefined ? o.value.durationMs : 0,
392
+ }));
386
393
  const flaky = await recordSuiteFlakes(deps.fs, deps.reticleRoot, parallelRuns);
394
+ await persistAndSyncVerificationRun(deps, timed, projectId);
387
395
  const verdict = buildSuiteVerdict(parallelRuns);
388
396
  return flaky.length > 0 ? { ...verdict, flaky: [...flaky] } : verdict;
389
397
  }
@@ -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
  };
@@ -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,7 +29,11 @@ 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:
@@ -5,6 +5,7 @@
5
5
  * block. Harnesses gate on it (e.g. "grade ≥ net AND integrity clean"). This is pure composition +
6
6
  * assessment — all five components already exist individually; wiring it onto results is a later step.
7
7
  */
8
+ import type { CaptureLoss } from '@reticlehq/core';
8
9
  /** Assertion grade, strongest first — the tier the verdict actually proved. */
9
10
  export declare const HonestyGrade: {
10
11
  readonly SIGNAL: "signal";
@@ -23,6 +24,15 @@ interface HonestyInputs {
23
24
  coveragePartial?: boolean;
24
25
  truncated?: boolean;
25
26
  blindSpots?: readonly string[];
27
+ /**
28
+ * The machine-readable half of `issues` — WHICH kind of loss, from core's closed `CaptureLoss`.
29
+ *
30
+ * `issues` is prose built for an agent to read, and prose is not something a dashboard can group
31
+ * by: `unclean_capture` became a large share of all field verdicts with no way to ask which of its three
32
+ * causes produced them. Passed by the caller rather than derived here, because only the caller
33
+ * knows whether a blind-spot note came from the browser transport or from a boundary in the page.
34
+ */
35
+ losses?: readonly CaptureLoss[];
26
36
  }
27
37
  export interface HonestyBlock {
28
38
  grade: HonestyGrade;
@@ -40,6 +50,7 @@ export interface HonestyBlock {
40
50
  integrity: {
41
51
  clean: boolean;
42
52
  issues: string[];
53
+ losses?: CaptureLoss[];
43
54
  };
44
55
  }
45
56
  export declare function buildHonestyBlock(inputs: HonestyInputs): HonestyBlock;
@@ -46,7 +46,15 @@ export function buildHonestyBlock(inputs) {
46
46
  ? {}
47
47
  : { envelope: { samples, sufficient: samples >= MIN_ENVELOPE_SAMPLES } }),
48
48
  coverage: { ...(pct === undefined ? {} : { pct }), partial },
49
- integrity: { clean: 0 === issues.length, issues },
49
+ integrity: {
50
+ clean: 0 === issues.length,
51
+ issues,
52
+ // Only when something was actually lost. An empty array beside `clean: true` would be a field
53
+ // that is always present and always says nothing, which is how a signal gets tuned out.
54
+ ...(0 === issues.length || undefined === inputs.losses || 0 === inputs.losses.length
55
+ ? {}
56
+ : { losses: [...new Set(inputs.losses)] }),
57
+ },
50
58
  };
51
59
  }
52
60
  /** Whether a verdict's honesty block clears the harness's bar. Reasons list every failed criterion. */
@@ -0,0 +1,40 @@
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
+ /**
26
+ * The wait, in one phrase.
27
+ *
28
+ * The default matters most: a call that declares no `until` waits for the page to go IDLE, and an
29
+ * agent that never chose that wait cannot be expected to recognise it in a timeout about it.
30
+ */
31
+ export declare function describeWaitTarget(predicate: Predicate): string;
32
+ /**
33
+ * The `because` sentence for an unsettled verdict: the wait, the observation, the next move.
34
+ *
35
+ * The two branches are the point. An outstanding write is worth waiting for and worth asserting
36
+ * directly; page churn is neither, and telling an agent to raise its timeout on churn sells it a
37
+ * slower dead end. Without the detail the sentence is the one that shipped before, so a caller that
38
+ * cannot supply the facts loses nothing.
39
+ */
40
+ export declare function unsettledBecause(base: string, window: UnsettledWindow | undefined): string;
@@ -0,0 +1,78 @@
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
+ return (`${base}. It waited for ${window.waitedFor}, and no request started in this window was left ` +
68
+ 'open — what kept the page busy was its own churn (a poll, a timer, an animation), so waiting ' +
69
+ 'longer will not help. Assert the consequence you care about (a signal, a route change, or ' +
70
+ 'store state) instead of waiting for idle, and re-check if the app is genuinely slow');
71
+ }
72
+ const shown = window.stillInFlight.slice(0, MAX_LISTED_REQUESTS);
73
+ const rest = window.stillInFlight.length - shown.length;
74
+ return (`${base}. It waited for ${window.waitedFor}, and ${String(window.stillInFlight.length)} ` +
75
+ `request(s) started in this window had not come back: ${shown.join(', ')}` +
76
+ `${rest > 0 ? `, and ${String(rest)} more` : ''} — re-check once they land, raise timeout_ms, ` +
77
+ 'or assert the request itself so the wait ends on the response rather than on idle');
78
+ }
@@ -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
  *
@@ -51,6 +52,12 @@ interface VerifiedInputs {
51
52
  * never read. See `hasUnreadWriteOutcome` — the status line describes the transport, not the result.
52
53
  */
53
54
  outcomeUnread?: boolean;
55
+ /**
56
+ * What the wait was for and what the window held when it ended — read ONLY by the two clauses that
57
+ * answer UNSETTLED, which is the commonest reason a verdict comes back `unknown` and was also the
58
+ * least actionable. Optional: without it those clauses say exactly what they said before.
59
+ */
60
+ unsettled?: UnsettledWindow;
54
61
  }
55
62
  interface VerifiedVerdict {
56
63
  verified: Verified;