@reticlehq/server 2.7.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 (236) hide show
  1. package/README.md +14 -5
  2. package/SKILL.md +64 -29
  3. package/dist/capsule/causal-summary.d.ts +20 -0
  4. package/dist/capsule/causal-summary.js +31 -6
  5. package/dist/capsule/divergence.js +7 -1
  6. package/dist/cli/chromium-hint.d.ts +33 -5
  7. package/dist/cli/chromium-hint.js +82 -12
  8. package/dist/cli/cli-doctor.js +28 -25
  9. package/dist/cli/cli-kill.d.ts +58 -0
  10. package/dist/cli/cli-kill.js +162 -0
  11. package/dist/cli/cli-launch.d.ts +17 -0
  12. package/dist/cli/cli-launch.js +28 -0
  13. package/dist/cli/cli-parse.d.ts +16 -1
  14. package/dist/cli/cli-parse.js +32 -9
  15. package/dist/cli/cli-port.d.ts +8 -0
  16. package/dist/cli/cli-port.js +16 -0
  17. package/dist/cli/cli-verify.js +2 -2
  18. package/dist/cli/doctor-sessions-line.d.ts +12 -0
  19. package/dist/cli/doctor-sessions-line.js +66 -0
  20. package/dist/cli/port-holder.d.ts +10 -0
  21. package/dist/cli/port-holder.js +26 -2
  22. package/dist/cli.js +99 -14
  23. package/dist/crawl/crawl-tools.js +12 -2
  24. package/dist/crawl/crawl.d.ts +13 -0
  25. package/dist/crawl/crawl.js +50 -2
  26. package/dist/events/contradictions.d.ts +14 -0
  27. package/dist/events/contradictions.js +26 -17
  28. package/dist/events/predicate-eval.d.ts +20 -1
  29. package/dist/events/predicate-eval.js +125 -3
  30. package/dist/events/predicate.js +170 -18
  31. package/dist/flows/flow-classify.js +1 -1
  32. package/dist/flows/flow-tools.js +12 -4
  33. package/dist/flows/heal-run.js +1 -1
  34. package/dist/honesty/blind-spots.js +6 -2
  35. package/dist/honesty/unsettled.d.ts +40 -0
  36. package/dist/honesty/unsettled.js +78 -0
  37. package/dist/honesty/verified.d.ts +7 -0
  38. package/dist/honesty/verified.js +3 -3
  39. package/dist/index.js +3 -0
  40. package/dist/init/agent-rules.d.ts +16 -0
  41. package/dist/init/agent-rules.js +96 -22
  42. package/dist/init/astro-patch.d.ts +2 -1
  43. package/dist/init/astro-patch.js +11 -5
  44. package/dist/init/capabilities.d.ts +22 -0
  45. package/dist/init/capabilities.js +76 -0
  46. package/dist/init/confirm.d.ts +96 -0
  47. package/dist/init/confirm.js +138 -0
  48. package/dist/init/cra.js +3 -2
  49. package/dist/init/non-js-project.js +29 -5
  50. package/dist/init/plan-framework.js +53 -15
  51. package/dist/init/plan.d.ts +40 -4
  52. package/dist/init/plan.js +163 -40
  53. package/dist/init/run.d.ts +24 -2
  54. package/dist/init/run.js +148 -42
  55. package/dist/init/slash-command.d.ts +1 -1
  56. package/dist/init/slash-command.js +46 -6
  57. package/dist/init/snippets.d.ts +44 -4
  58. package/dist/init/snippets.js +161 -37
  59. package/dist/init/vite-config.js +29 -3
  60. package/dist/input/network-mock-tools.d.ts +19 -0
  61. package/dist/input/network-mock-tools.js +29 -4
  62. package/dist/license/license.js +1 -1
  63. package/dist/mcp/mcp-proxy.d.ts +69 -3
  64. package/dist/mcp/mcp-proxy.js +205 -20
  65. package/dist/mcp/mcp.js +23 -7
  66. package/dist/mcp/proxy-lifecycle.d.ts +1 -1
  67. package/dist/mcp/proxy-lifecycle.js +1 -1
  68. package/dist/pool/browser-pool.d.ts +22 -2
  69. package/dist/pool/browser-pool.js +44 -4
  70. package/dist/project/learned-routes.d.ts +24 -0
  71. package/dist/project/learned-routes.js +63 -0
  72. package/dist/project/project-store.d.ts +6 -0
  73. package/dist/project/project-store.js +25 -1
  74. package/dist/session/no-session-diagnosis.d.ts +19 -0
  75. package/dist/session/no-session-diagnosis.js +71 -18
  76. package/dist/session/no-session-watch.js +12 -0
  77. package/dist/session/ref-provenance.d.ts +54 -0
  78. package/dist/session/ref-provenance.js +61 -0
  79. package/dist/session/session-lease.js +1 -1
  80. package/dist/telemetry/automation-hint.d.ts +17 -0
  81. package/dist/telemetry/automation-hint.js +71 -0
  82. package/dist/telemetry/bug-found.d.ts +0 -20
  83. package/dist/telemetry/bug-found.js +38 -21
  84. package/dist/telemetry/cli-telemetry.js +7 -1
  85. package/dist/telemetry/feedback.js +1 -1
  86. package/dist/telemetry/init-telemetry.js +9 -1
  87. package/dist/telemetry/install-source.d.ts +36 -0
  88. package/dist/telemetry/install-source.js +40 -0
  89. package/dist/telemetry/run-telemetry.js +4 -1
  90. package/dist/telemetry/session-metrics.js +5 -0
  91. package/dist/telemetry/telemetry.d.ts +5 -1
  92. package/dist/telemetry/telemetry.js +15 -2
  93. package/dist/telemetry/tool-refused.d.ts +19 -0
  94. package/dist/telemetry/tool-refused.js +55 -0
  95. package/dist/telemetry/verification-of.js +46 -7
  96. package/dist/temp-dir.d.ts +27 -0
  97. package/dist/temp-dir.js +46 -0
  98. package/dist/tools/act-tools.js +45 -1
  99. package/dist/tools/assert-grade.d.ts +1 -1
  100. package/dist/tools/assert-grade.js +27 -5
  101. package/dist/tools/assert-verdict.js +18 -1
  102. package/dist/tools/contract-tools.js +4 -0
  103. package/dist/tools/dynamic-tools.js +1 -1
  104. package/dist/tools/error-recovery.d.ts +10 -0
  105. package/dist/tools/error-recovery.js +64 -4
  106. package/dist/tools/feedback-invite.js +1 -1
  107. package/dist/tools/invoke-tool.js +139 -23
  108. package/dist/tools/lease-tools.d.ts +29 -1
  109. package/dist/tools/lease-tools.js +58 -5
  110. package/dist/tools/navigate-result.js +1 -1
  111. package/dist/tools/observe-tools.js +1 -1
  112. package/dist/tools/observed-nothing.js +1 -1
  113. package/dist/tools/react-grace.d.ts +48 -0
  114. package/dist/tools/react-grace.js +87 -0
  115. package/dist/tools/read-tools.js +20 -4
  116. package/dist/tools/settle-in-flight.d.ts +79 -0
  117. package/dist/tools/settle-in-flight.js +106 -0
  118. package/dist/tools/snapshot-delta.d.ts +7 -0
  119. package/dist/tools/snapshot-delta.js +130 -6
  120. package/dist/tools/tool-surface.js +19 -0
  121. package/dist/tools/tools.js +16 -4
  122. package/docs/README.md +56 -11
  123. package/docs/actions.mdx +87 -0
  124. package/docs/agent-cheatsheet.md +93 -29
  125. package/docs/architecture.md +46 -35
  126. package/docs/benchmarks.md +76 -72
  127. package/docs/best-practices.mdx +211 -0
  128. package/docs/capabilities.mdx +387 -0
  129. package/docs/cli/affected.mdx +51 -0
  130. package/docs/cli/capsules.mdx +69 -0
  131. package/docs/cli/cloud.mdx +127 -0
  132. package/docs/cli/doctor.mdx +76 -0
  133. package/docs/cli/drive.mdx +55 -0
  134. package/docs/cli/feedback.mdx +67 -0
  135. package/docs/cli/gate.mdx +64 -0
  136. package/docs/cli/help.mdx +92 -0
  137. package/docs/cli/hunt.mdx +57 -0
  138. package/docs/cli/identify.mdx +62 -0
  139. package/docs/cli/init.mdx +94 -0
  140. package/docs/cli/kill.mdx +91 -0
  141. package/docs/cli/license.mdx +48 -0
  142. package/docs/cli/mcp.mdx +61 -0
  143. package/docs/cli/open.mdx +68 -0
  144. package/docs/cli/restart.mdx +60 -0
  145. package/docs/cli/rollback.mdx +35 -0
  146. package/docs/cli/serve.mdx +62 -0
  147. package/docs/cli/status.mdx +64 -0
  148. package/docs/cli/stop.mdx +56 -0
  149. package/docs/cli/telemetry.mdx +57 -0
  150. package/docs/cli/update.mdx +52 -0
  151. package/docs/cli/verify.mdx +57 -0
  152. package/docs/cli/version.mdx +39 -0
  153. package/docs/cli/watch.mdx +55 -0
  154. package/docs/cli.mdx +219 -0
  155. package/docs/debugging.md +13 -9
  156. package/docs/deploy-checks.md +15 -11
  157. package/docs/desktop-apps.md +49 -40
  158. package/docs/desktop.mdx +188 -0
  159. package/docs/docs.json +363 -0
  160. package/docs/enterprise.md +20 -16
  161. package/docs/faq.mdx +88 -0
  162. package/docs/fixtures.md +11 -7
  163. package/docs/flows.md +26 -22
  164. package/docs/for-agents.md +71 -0
  165. package/docs/frameworks.mdx +199 -0
  166. package/docs/gate-plan.md +45 -41
  167. package/docs/gates.md +22 -18
  168. package/docs/getting-started.md +76 -65
  169. package/docs/human-control.md +22 -18
  170. package/docs/index.mdx +101 -0
  171. package/docs/install-agentic.mdx +215 -0
  172. package/docs/install-manual.mdx +194 -0
  173. package/docs/instrumentation.mdx +232 -0
  174. package/docs/integration-patterns.md +38 -34
  175. package/docs/local-registry.md +16 -12
  176. package/docs/multi-agent-testing.md +16 -12
  177. package/docs/packages/babel-plugin.mdx +70 -0
  178. package/docs/packages/browser.mdx +111 -0
  179. package/docs/packages/core.mdx +106 -0
  180. package/docs/packages/electron.mdx +87 -0
  181. package/docs/packages/eslint-plugin.mdx +84 -0
  182. package/docs/packages/next.mdx +91 -0
  183. package/docs/packages/react.mdx +91 -0
  184. package/docs/packages/server.mdx +98 -0
  185. package/docs/packages/tauri.mdx +63 -0
  186. package/docs/packages/test.mdx +108 -0
  187. package/docs/packages/vite-plugin.mdx +96 -0
  188. package/docs/packages.mdx +217 -0
  189. package/docs/platform-integration.md +33 -26
  190. package/docs/predicates.mdx +179 -0
  191. package/docs/quickstart.mdx +211 -0
  192. package/docs/recipes.mdx +242 -0
  193. package/docs/skill-file.mdx +105 -0
  194. package/docs/state-management.mdx +197 -0
  195. package/docs/style.css +14 -0
  196. package/docs/system-map.md +47 -32
  197. package/docs/telemetry-contract.md +93 -50
  198. package/docs/telemetry-events.mdx +430 -0
  199. package/docs/telemetry.md +57 -52
  200. package/docs/testing.md +10 -6
  201. package/docs/token-efficiency.md +30 -26
  202. package/docs/tools-act-and-wait.mdx +123 -0
  203. package/docs/tools-act-sequence.mdx +120 -0
  204. package/docs/tools-act.mdx +82 -0
  205. package/docs/tools-assert.mdx +70 -0
  206. package/docs/tools-console.mdx +59 -0
  207. package/docs/tools-inspect.mdx +85 -0
  208. package/docs/tools-navigate.mdx +63 -0
  209. package/docs/tools-network.mdx +86 -0
  210. package/docs/tools-observe.mdx +89 -0
  211. package/docs/tools-overview.mdx +157 -0
  212. package/docs/tools-query.mdx +82 -0
  213. package/docs/tools-session-and-feedback.mdx +91 -0
  214. package/docs/tools-sessions.mdx +65 -0
  215. package/docs/tools-snapshot.mdx +75 -0
  216. package/docs/tools-state.mdx +81 -0
  217. package/docs/tools-tools-and-run.mdx +125 -0
  218. package/docs/tools-wait-for.mdx +66 -0
  219. package/docs/troubleshooting.mdx +193 -0
  220. package/docs/usage.md +175 -153
  221. package/docs/vs-chrome-devtools-mcp.mdx +82 -0
  222. package/docs/vs-playwright-mcp.mdx +85 -0
  223. package/docs/vs-screenshots.mdx +96 -0
  224. package/docs/what-is-reticle.mdx +63 -0
  225. package/docs/why-reticle.mdx +107 -0
  226. package/package.json +2 -2
  227. package/docs/matrix/2.5.0/claude-code-machine.json +0 -19
  228. package/docs/matrix/2.5.0/codex-machine.json +0 -20
  229. package/docs/matrix/2.5.0/cursor-machine.json +0 -22
  230. package/docs/matrix/2.5.0/gemini-machine.json +0 -22
  231. package/docs/matrix/2.5.0/opencode-machine.json +0 -22
  232. package/docs/matrix/2.5.0/vscode-machine.json +0 -22
  233. package/docs/matrix/2.5.0/windsurf-machine.json +0 -22
  234. package/docs/matrix/MATRIX.md +0 -26
  235. package/docs/matrix/README.md +0 -59
  236. package/docs/mint.json +0 -61
@@ -1,4 +1,4 @@
1
- import { ElementQuerySchema, ElementState, EventType, PredicateKind, StreamDirection, isDevToolingUrl, } from '@reticlehq/core';
1
+ import { ElementQuerySchema, ElementState, EventType, PredicateKind, QueryBy, StreamDirection, isDevToolingUrl, } from '@reticlehq/core';
2
2
  import { z } from 'zod';
3
3
  import { describeObserved } from './observed-in-window.js';
4
4
  /**
@@ -43,6 +43,98 @@ const ELEMENT_QUERY_FIELDS = [
43
43
  'alt',
44
44
  'component',
45
45
  ];
46
+ /**
47
+ * The locator fields the browser actually CONSUMES for a given query — mirrors the precedence in
48
+ * `findIn` (packages/browser/src/dom/query.ts).
49
+ *
50
+ * An element query is not a conjunction. It is a first-match dispatch: `by`+`value` wins, then the
51
+ * component/source anchor, then `role` (which alone also consumes `name`), then the first of
52
+ * text/label/placeholder/testid/alt that is present. Every OTHER field the caller wrote is dropped on
53
+ * the floor, silently, and the match reported as if the whole query had been honoured.
54
+ *
55
+ * Duplicated here on purpose. The alternative is to send the question to the browser, and the browser
56
+ * cannot answer it: by the time a match comes back, the fields it ignored are indistinguishable from
57
+ * the fields it used.
58
+ */
59
+ function usedQueryFields(query) {
60
+ const used = new Set();
61
+ if (query.by !== undefined && query.value !== undefined) {
62
+ used.add('by').add('value');
63
+ if (QueryBy.ROLE === query.by)
64
+ used.add('name');
65
+ if (QueryBy.COMPONENT === query.by)
66
+ used.add('component');
67
+ return used;
68
+ }
69
+ if (query.component !== undefined || query.source !== undefined)
70
+ return used.add('component');
71
+ if (query.role !== undefined)
72
+ return used.add('role').add('name');
73
+ for (const field of ['text', 'label', 'placeholder', 'testid', 'alt']) {
74
+ if (query[field] !== undefined)
75
+ return used.add(field);
76
+ }
77
+ return used;
78
+ }
79
+ /**
80
+ * How a dropped field is checked back on the server, against the descriptor the match returned.
81
+ *
82
+ * `value` is the field this exists for: `{ role: "textbox", name: "GST amount", value: "274.58" }`
83
+ * read as a locator has no `by`, so the value half was discarded and the predicate collapsed to "a
84
+ * textbox named GST amount exists" — trivially true against an EMPTY field. Comparison is TRIMMED and
85
+ * exact: an input's value is a value, not prose, and a trailing space in either the app or the
86
+ * predicate is not a finding anybody wants. `""` asserts the field is empty, which describe() reports
87
+ * by omitting the field entirely.
88
+ *
89
+ * `role`/`name`/`value` compare against exactly what `reticle_query` REPORTS, so the words an agent
90
+ * copies out of a query result are the words that match here. `text` is a substring match, matching
91
+ * Testing Library's `exact: false`, and falls back to the name because describe() omits `text` when it
92
+ * equals the accessible name.
93
+ */
94
+ const RESIDUAL_CHECKS = {
95
+ value: (element, want) => (element.value ?? '').trim() === want.trim(),
96
+ role: (element, want) => element.role === want,
97
+ name: (element, want) => element.name.trim() === want.trim(),
98
+ text: (element, want) => (element.text ?? element.name).includes(want),
99
+ };
100
+ /**
101
+ * Split a query's dropped fields into the ones the server can still enforce and the ones it cannot.
102
+ *
103
+ * The alternative — refuse every dropped field — breaks calls that work today and that our own
104
+ * cheatsheet advertises (`{ role: "button", text: "Save" }`), and breaks them into no verdict at all.
105
+ * Enforcing what we can and refusing only the rest keeps those calls working AS WRITTEN, which is the
106
+ * outcome the caller was already assuming.
107
+ */
108
+ export function residualQueryChecks(query) {
109
+ const used = usedQueryFields(query);
110
+ const checks = [];
111
+ const unusable = [];
112
+ for (const field of ELEMENT_QUERY_FIELDS) {
113
+ const want = query[field];
114
+ if (want === undefined || used.has(field))
115
+ continue;
116
+ if ('string' === typeof want && RESIDUAL_CHECKS[field] !== undefined)
117
+ checks.push([field, want]);
118
+ else
119
+ unusable.push(field);
120
+ }
121
+ return { checks, unusable };
122
+ }
123
+ /** Does this element satisfy every field the locator dropped? */
124
+ export function satisfiesResiduals(element, checks) {
125
+ return checks.every(([field, want]) => true === RESIDUAL_CHECKS[field]?.(element, want));
126
+ }
127
+ /** How the element's own reading of a dropped field should be REPORTED back on a failure. */
128
+ export function describeResidual(element, field) {
129
+ const reading = 'value' === field
130
+ ? (element.value ?? '')
131
+ : 'text' === field
132
+ ? (element.text ?? element.name)
133
+ : 'role' === field
134
+ ? element.role
135
+ : element.name;
136
+ return `${element.role} "${element.name}" ${field}=${JSON.stringify(reading)}`;
137
+ }
46
138
  /** Rename known aliases before parse; an explicit canonical key always wins. */
47
139
  function applyPredicateAliases(input) {
48
140
  if (typeof input !== 'object' || null === input || Array.isArray(input))
@@ -256,7 +348,37 @@ export function matchValue(got, want) {
256
348
  }
257
349
  return true;
258
350
  }
259
- return got === want;
351
+ return structurallyEqual(got, want);
352
+ }
353
+ /**
354
+ * Value equality for the leaf comparison, because `===` could never match a literal.
355
+ *
356
+ * The expected side of a predicate is parsed out of the agent's JSON, so it is a fresh object every
357
+ * time — reference equality made `equals: ["a", "b"]` false against a store holding exactly
358
+ * `["a", "b"]`, and there was no value of the app's state that could have made it true. The mirror of
359
+ * a false green: an assertion nobody could satisfy, on the commonest thing there is to assert about.
360
+ *
361
+ * Deliberately strict about shape. An array is not an object, an extra key is a difference, and order
362
+ * is part of a list's value — `equals` means equals; `dataMatches` is the field-by-field one.
363
+ */
364
+ function structurallyEqual(got, want) {
365
+ if (got === want)
366
+ return true;
367
+ if (null === got || null === want)
368
+ return false;
369
+ if ('object' !== typeof got || 'object' !== typeof want)
370
+ return false;
371
+ if (Array.isArray(got) !== Array.isArray(want))
372
+ return false;
373
+ if (Array.isArray(got) && Array.isArray(want)) {
374
+ return got.length === want.length && got.every((v, i) => structurallyEqual(v, want[i]));
375
+ }
376
+ const a = got;
377
+ const b = want;
378
+ const keys = Object.keys(a);
379
+ if (keys.length !== Object.keys(b).length)
380
+ return false;
381
+ return keys.every((k) => k in b && structurallyEqual(a[k], b[k]));
260
382
  }
261
383
  /** Shallow JSON pattern match: each key in `pattern` must match (see matchValue). */
262
384
  function dataMatches(actual, pattern) {
@@ -486,7 +608,7 @@ export function evalSignal(events, p) {
486
608
  ? `signal '${p.name ?? '(any)'}' fired ${String(sameName.length)}x, payload: ${JSON.stringify(sameName[0])}`
487
609
  : // Name what DID fire: a typo'd signal name and a genuinely dead action produce the same
488
610
  // sentence otherwise, and the agent cannot tell them apart. See observed-in-window.ts.
489
- `signal '${p.name ?? '(any)'}' never fired — ${describeObserved('signals', events.filter((e) => e.type === EventType.SIGNAL).map((e) => str(e.data['name']) ?? ''))}`,
611
+ `signal '${p.name ?? '(any)'}' never fired; ${describeObserved('signals', events.filter((e) => e.type === EventType.SIGNAL).map((e) => str(e.data['name']) ?? ''))}`,
490
612
  expected: p.dataMatches === undefined
491
613
  ? `signal '${p.name ?? '(any)'}' to fire`
492
614
  : `signal '${p.name ?? '(any)'}' with payload matching ${JSON.stringify(p.dataMatches)}`,
@@ -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 };
@@ -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:
@@ -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;
@@ -1,5 +1,6 @@
1
1
  import { Verified, VerifiedReason, isAbsenceDerived } from '@reticlehq/core';
2
2
  import { HonestyGrade } from './honesty.js';
3
+ import { unsettledBecause } from './unsettled.js';
3
4
  export function decideVerified(inputs) {
4
5
  const { pass, honesty, contradictions = [], settled, outcomePending, outcomeUnread } = inputs;
5
6
  // Ahead of the failure clause, because a failure is only the most actionable fact when there WAS
@@ -68,8 +69,7 @@ export function decideVerified(inputs) {
68
69
  return {
69
70
  verified: Verified.UNKNOWN,
70
71
  verifiedReason: VerifiedReason.UNSETTLED,
71
- because: `the assertion held, but this window closed before the app finished (${kinds}) — ` +
72
- `re-check, or assert a consequence that settles`,
72
+ because: unsettledBecause(`the assertion held, but this window closed before the app finished (${kinds})`, inputs.unsettled),
73
73
  };
74
74
  }
75
75
  // Below the contradiction clause, and for the same reason a contradiction outranks a dirty
@@ -144,7 +144,7 @@ export function decideVerified(inputs) {
144
144
  return {
145
145
  verified: Verified.UNKNOWN,
146
146
  verifiedReason: VerifiedReason.UNSETTLED,
147
- because: 'the page never settled, so the reaction window may have closed before the app finished',
147
+ because: unsettledBecause('the page never settled, so the reaction window may have closed before the app finished', inputs.unsettled),
148
148
  };
149
149
  }
150
150
  // Partial coverage is a real caveat but not a blocker — it is reported in `honesty.coverage` and