@reticlehq/server 2.10.0 → 2.12.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 (290) hide show
  1. package/SKILL.md +73 -11
  2. package/dist/bridge/bridge.d.ts +0 -13
  3. package/dist/bridge/bridge.js +1 -1
  4. package/dist/bridge/bridge.test-harness.d.ts +2 -0
  5. package/dist/bridge/bridge.test-harness.js +8 -1
  6. package/dist/capsule/capsule-store.d.ts +2 -1
  7. package/dist/capsule/capsule-store.js +1 -1
  8. package/dist/capsule/causal-summary.d.ts +2 -2
  9. package/dist/chromium-launch-options.d.ts +28 -0
  10. package/dist/chromium-launch-options.js +29 -0
  11. package/dist/cli/cli-doctor.js +8 -0
  12. package/dist/cli/cli-flow-commands.d.ts +3 -1
  13. package/dist/cli/cli-flow-commands.js +34 -4
  14. package/dist/cli/cli-kill.d.ts +2 -1
  15. package/dist/cli/cli-parse.d.ts +3 -1
  16. package/dist/cli/cli-parse.js +29 -7
  17. package/dist/cli/cli-update-commands.js +20 -1
  18. package/dist/cli/cli-verify.d.ts +1 -1
  19. package/dist/cli/cli-verify.js +2 -2
  20. package/dist/cli/cloud-cli.js +12 -6
  21. package/dist/cli/config-discovery.d.ts +2 -1
  22. package/dist/cli/doctor-rows.d.ts +3 -1
  23. package/dist/cli/doctor-rows.js +3 -1
  24. package/dist/cli/gate-exit.d.ts +21 -0
  25. package/dist/cli/gate-exit.js +21 -0
  26. package/dist/cli/gate-hook-message.d.ts +33 -0
  27. package/dist/cli/gate-hook-message.js +46 -0
  28. package/dist/cli/mcp-command.d.ts +25 -0
  29. package/dist/cli/mcp-command.js +143 -0
  30. package/dist/cli/sibling-ports.d.ts +37 -0
  31. package/dist/cli/sibling-ports.js +56 -0
  32. package/dist/cli.js +37 -110
  33. package/dist/cloud/cloud-config.d.ts +2 -1
  34. package/dist/cloud/cloud-sync.d.ts +11 -0
  35. package/dist/cloud/cloud-sync.js +32 -0
  36. package/dist/crawl/crawl.d.ts +9 -13
  37. package/dist/crawl/crawl.js +20 -3
  38. package/dist/daemon/daemon-resolve.d.ts +79 -0
  39. package/dist/daemon/daemon-resolve.js +118 -0
  40. package/dist/daemon/free-port.d.ts +29 -0
  41. package/dist/daemon/free-port.js +55 -0
  42. package/dist/daemon/heartbeat.d.ts +0 -4
  43. package/dist/daemon/heartbeat.js +2 -2
  44. package/dist/dev/stale-issue-guard.d.ts +41 -0
  45. package/dist/dev/stale-issue-guard.js +75 -0
  46. package/dist/domain/domain-tools.js +3 -2
  47. package/dist/events/contradictions.d.ts +41 -0
  48. package/dist/events/contradictions.js +125 -12
  49. package/dist/events/declared.d.ts +14 -0
  50. package/dist/events/declared.js +33 -0
  51. package/dist/events/echo-mismatch.js +14 -0
  52. package/dist/events/observed-in-window.d.ts +12 -0
  53. package/dist/events/observed-in-window.js +17 -0
  54. package/dist/events/predicate-asks.d.ts +30 -0
  55. package/dist/events/predicate-asks.js +48 -0
  56. package/dist/events/predicate-eval.js +254 -25
  57. package/dist/events/predicate-parse.d.ts +0 -6
  58. package/dist/events/predicate-parse.js +51 -5
  59. package/dist/events/predicate-schema.d.ts +62 -0
  60. package/dist/events/predicate-schema.js +162 -4
  61. package/dist/events/predicate.d.ts +15 -0
  62. package/dist/events/predicate.js +41 -2
  63. package/dist/flows/coverage.d.ts +0 -8
  64. package/dist/flows/coverage.js +1 -1
  65. package/dist/flows/decision.d.ts +5 -1
  66. package/dist/flows/decision.js +33 -7
  67. package/dist/flows/flake.d.ts +0 -2
  68. package/dist/flows/flake.js +1 -1
  69. package/dist/flows/flow-intent.d.ts +52 -0
  70. package/dist/flows/flow-intent.js +123 -0
  71. package/dist/flows/flow-replay-run.js +27 -4
  72. package/dist/flows/flow-success.js +15 -3
  73. package/dist/flows/flow-tools.js +65 -8
  74. package/dist/flows/flows.d.ts +6 -1
  75. package/dist/flows/flows.js +34 -22
  76. package/dist/flows/predicate-to-expect.js +14 -3
  77. package/dist/flows/server-verify.js +16 -2
  78. package/dist/flows/verify-change-tools.js +9 -2
  79. package/dist/honesty/blind-spots.d.ts +8 -16
  80. package/dist/honesty/blind-spots.js +18 -5
  81. package/dist/honesty/feature-capture.d.ts +150 -0
  82. package/dist/honesty/feature-capture.js +233 -0
  83. package/dist/honesty/gap-ledger.d.ts +52 -0
  84. package/dist/honesty/gap-ledger.js +63 -0
  85. package/dist/honesty/instrumentation-gaps.d.ts +79 -0
  86. package/dist/honesty/instrumentation-gaps.js +77 -0
  87. package/dist/honesty/observability.d.ts +37 -0
  88. package/dist/honesty/observability.js +40 -0
  89. package/dist/honesty/tool-hit-rate.d.ts +86 -0
  90. package/dist/honesty/tool-hit-rate.js +90 -0
  91. package/dist/honesty/undeclared-change.d.ts +51 -0
  92. package/dist/honesty/undeclared-change.js +56 -0
  93. package/dist/honesty/verified.d.ts +15 -0
  94. package/dist/honesty/verified.js +68 -4
  95. package/dist/http-server.d.ts +0 -1
  96. package/dist/http-server.js +1 -1
  97. package/dist/hunt/hunt-report.d.ts +0 -2
  98. package/dist/hunt/hunt-report.js +1 -1
  99. package/dist/impact/impact-store.d.ts +0 -5
  100. package/dist/impact/impact-store.js +1 -1
  101. package/dist/index.d.ts +4 -2
  102. package/dist/index.js +41 -0
  103. package/dist/init/agent-rules.d.ts +17 -0
  104. package/dist/init/agent-rules.js +31 -1
  105. package/dist/init/capabilities.d.ts +0 -2
  106. package/dist/init/capabilities.js +1 -1
  107. package/dist/init/closing-hint.d.ts +36 -0
  108. package/dist/init/closing-hint.js +120 -0
  109. package/dist/init/confirm.d.ts +0 -25
  110. package/dist/init/confirm.js +3 -3
  111. package/dist/init/cra.d.ts +14 -1
  112. package/dist/init/cra.js +43 -12
  113. package/dist/init/dev-script.d.ts +48 -0
  114. package/dist/init/dev-script.js +63 -0
  115. package/dist/init/mcp.d.ts +16 -5
  116. package/dist/init/mcp.js +26 -10
  117. package/dist/init/node-io.js +3 -0
  118. package/dist/init/plan-framework.d.ts +0 -5
  119. package/dist/init/plan-framework.js +83 -9
  120. package/dist/init/plan.d.ts +10 -30
  121. package/dist/init/plan.js +30 -15
  122. package/dist/init/refresh-rules.d.ts +39 -0
  123. package/dist/init/refresh-rules.js +51 -0
  124. package/dist/init/run.d.ts +2 -10
  125. package/dist/init/run.js +26 -68
  126. package/dist/init/slash-command.d.ts +1 -1
  127. package/dist/init/slash-command.js +33 -10
  128. package/dist/init/snippets.js +53 -10
  129. package/dist/input/real-input.d.ts +2 -0
  130. package/dist/input/real-input.js +3 -2
  131. package/dist/input/scroll-find.d.ts +7 -4
  132. package/dist/input/scroll-find.js +63 -25
  133. package/dist/intent/inline-intent.d.ts +46 -0
  134. package/dist/intent/inline-intent.js +103 -0
  135. package/dist/intent/intent-store.d.ts +37 -0
  136. package/dist/intent/intent-store.js +112 -0
  137. package/dist/intent/intent-tools.d.ts +2 -0
  138. package/dist/intent/intent-tools.js +80 -0
  139. package/dist/intent/open-intents.d.ts +21 -0
  140. package/dist/intent/open-intents.js +25 -0
  141. package/dist/journal/envelope.d.ts +2 -2
  142. package/dist/journal/journal-recorder.d.ts +23 -0
  143. package/dist/journal/journal-recorder.js +35 -0
  144. package/dist/license/license.d.ts +8 -2
  145. package/dist/license/license.js +10 -15
  146. package/dist/mcp/attach-memory.d.ts +67 -0
  147. package/dist/mcp/attach-memory.js +142 -0
  148. package/dist/mcp/mcp-proxy.d.ts +0 -7
  149. package/dist/mcp/mcp-proxy.js +14 -2
  150. package/dist/mcp/mcp.d.ts +2 -0
  151. package/dist/mcp/mcp.js +45 -23
  152. package/dist/mcp/server-instructions.js +45 -3
  153. package/dist/mcp/tool-catalog-cache.d.ts +8 -0
  154. package/dist/mcp/tool-catalog-cache.js +10 -0
  155. package/dist/oracles/flow-instrument-gaps.d.ts +4 -4
  156. package/dist/oracles/flow-instrument-gaps.js +4 -5
  157. package/dist/oracles/self-instrument.d.ts +9 -9
  158. package/dist/oracles/self-instrument.js +19 -13
  159. package/dist/pool/browser-pool.d.ts +12 -1
  160. package/dist/pool/browser-pool.js +31 -1
  161. package/dist/pool/playwright-launcher.js +3 -1
  162. package/dist/project/artifact-root.d.ts +83 -0
  163. package/dist/project/artifact-root.js +112 -0
  164. package/dist/project/fs-port.d.ts +3 -0
  165. package/dist/project/fs-port.js +3 -2
  166. package/dist/project/memory-fs.d.ts +9 -0
  167. package/dist/project/memory-fs.js +93 -0
  168. package/dist/project/project-store.d.ts +17 -0
  169. package/dist/project/project-store.js +33 -0
  170. package/dist/project/project-tools.js +3 -32
  171. package/dist/project/remember-project.d.ts +24 -0
  172. package/dist/project/remember-project.js +40 -0
  173. package/dist/project/reticle-dir.d.ts +2 -0
  174. package/dist/project/reticle-dir.js +1 -0
  175. package/dist/project/session-root.d.ts +15 -0
  176. package/dist/project/session-root.js +31 -0
  177. package/dist/runs/context-tools.d.ts +2 -0
  178. package/dist/runs/context-tools.js +89 -0
  179. package/dist/runs/run-context.d.ts +40 -0
  180. package/dist/runs/run-context.js +158 -0
  181. package/dist/runs/run-diff.d.ts +2 -1
  182. package/dist/runs/run-tools.js +2 -1
  183. package/dist/runs/verification-sync.js +2 -2
  184. package/dist/session/dev-server-probe.d.ts +0 -8
  185. package/dist/session/dev-server-probe.js +1 -1
  186. package/dist/session/last-act.d.ts +7 -1
  187. package/dist/session/last-act.js +2 -2
  188. package/dist/session/lease-availability.d.ts +40 -0
  189. package/dist/session/lease-availability.js +43 -0
  190. package/dist/session/live-control-tools.js +1 -42
  191. package/dist/session/no-session-diagnosis.d.ts +20 -0
  192. package/dist/session/no-session-diagnosis.js +41 -12
  193. package/dist/session/no-session-next-action.d.ts +5 -0
  194. package/dist/session/no-session-next-action.js +35 -3
  195. package/dist/session/no-session-watch.d.ts +8 -0
  196. package/dist/session/no-session-watch.js +83 -65
  197. package/dist/session/output-budget.js +4 -1
  198. package/dist/session/session-health.d.ts +9 -0
  199. package/dist/session/session-health.js +23 -0
  200. package/dist/session/session-manager.js +50 -1
  201. package/dist/session/session-successor.d.ts +37 -0
  202. package/dist/session/session-successor.js +78 -0
  203. package/dist/session/session.d.ts +40 -1
  204. package/dist/session/session.js +84 -2
  205. package/dist/session/stall-clock.d.ts +18 -0
  206. package/dist/session/stall-clock.js +48 -0
  207. package/dist/telemetry/automation-hint.d.ts +2 -1
  208. package/dist/telemetry/daemon-telemetry.js +12 -23
  209. package/dist/telemetry/feedback-outbox.d.ts +2 -1
  210. package/dist/telemetry/project-profile.d.ts +0 -8
  211. package/dist/telemetry/project-profile.js +1 -1
  212. package/dist/telemetry/run-telemetry.d.ts +0 -2
  213. package/dist/telemetry/run-telemetry.js +1 -1
  214. package/dist/telemetry/session-metrics.d.ts +1 -1
  215. package/dist/telemetry/session-metrics.js +1 -1
  216. package/dist/telemetry/telemetry.d.ts +12 -3
  217. package/dist/telemetry/telemetry.js +25 -5
  218. package/dist/telemetry/verification-of.js +2 -2
  219. package/dist/tools/act-tools.js +159 -24
  220. package/dist/tools/assert-source.d.ts +22 -0
  221. package/dist/tools/assert-source.js +53 -0
  222. package/dist/tools/assert-verdict.d.ts +21 -2
  223. package/dist/tools/assert-verdict.js +59 -4
  224. package/dist/tools/browser-tools.js +0 -20
  225. package/dist/tools/complete-snapshot.d.ts +48 -0
  226. package/dist/tools/complete-snapshot.js +75 -0
  227. package/dist/tools/contract-tools.js +5 -3
  228. package/dist/tools/coverage-tools.d.ts +10 -1
  229. package/dist/tools/coverage-tools.js +124 -37
  230. package/dist/tools/dynamic-tools.js +29 -8
  231. package/dist/tools/error-recovery.d.ts +24 -14
  232. package/dist/tools/error-recovery.js +70 -9
  233. package/dist/tools/feedback-tools.d.ts +1 -1
  234. package/dist/tools/feedback-tools.js +2 -2
  235. package/dist/tools/invoke-tool.js +62 -2
  236. package/dist/tools/lease-hint.d.ts +10 -0
  237. package/dist/tools/lease-hint.js +53 -4
  238. package/dist/tools/lease-tools.d.ts +5 -1
  239. package/dist/tools/lease-tools.js +22 -8
  240. package/dist/tools/navigate-arrival.d.ts +0 -2
  241. package/dist/tools/navigate-arrival.js +1 -1
  242. package/dist/tools/observe-tools.js +70 -27
  243. package/dist/tools/read-tools.js +5 -3
  244. package/dist/tools/real-input-attempt.d.ts +22 -0
  245. package/dist/tools/real-input-attempt.js +153 -1
  246. package/dist/tools/reconcile-tools.js +18 -4
  247. package/dist/tools/resolve-target.d.ts +0 -7
  248. package/dist/tools/sdk-marker-probe.d.ts +29 -0
  249. package/dist/tools/sdk-marker-probe.js +57 -0
  250. package/dist/tools/snapshot-delta.d.ts +1 -1
  251. package/dist/tools/tool-kit.d.ts +28 -1
  252. package/dist/tools/tool-kit.js +16 -0
  253. package/dist/tools/tool-names.d.ts +3 -0
  254. package/dist/tools/tool-names.js +3 -0
  255. package/dist/tools/tool-surface.d.ts +85 -6
  256. package/dist/tools/tool-surface.js +122 -2
  257. package/dist/tools/tools.js +22 -4
  258. package/dist/tools/unadvertised-help.js +4 -4
  259. package/dist/tools/verify-next-baton.d.ts +53 -0
  260. package/dist/tools/verify-next-baton.js +52 -0
  261. package/dist/update/update-nudge.d.ts +7 -10
  262. package/dist/update/update-nudge.js +12 -3
  263. package/dist/update/updater.d.ts +1 -1
  264. package/docs/README.md +3 -1
  265. package/docs/cli/cloud.mdx +1 -1
  266. package/docs/cli/doctor.mdx +1 -0
  267. package/docs/cli/verify.mdx +4 -5
  268. package/docs/desktop.mdx +1 -1
  269. package/docs/docs.json +3 -3
  270. package/docs/enterprise.md +3 -1
  271. package/docs/gates.md +1 -1
  272. package/docs/install-manual.mdx +1 -1
  273. package/docs/license-activation.mdx +145 -0
  274. package/docs/packages/browser.mdx +2 -2
  275. package/docs/packages/server.mdx +1 -1
  276. package/docs/packages.mdx +8 -6
  277. package/docs/platform-integration.md +17 -0
  278. package/docs/predicates.mdx +8 -0
  279. package/docs/reticle-manual.md +106 -0
  280. package/docs/telemetry-contract.md +1 -12
  281. package/docs/telemetry-events.mdx +1 -34
  282. package/docs/telemetry.md +0 -1
  283. package/docs/tools-tools-and-run.mdx +2 -4
  284. package/docs/troubleshooting.mdx +9 -0
  285. package/docs/usage.md +3 -2
  286. package/package.json +2 -2
  287. package/dist/session/session-readiness.d.ts +0 -34
  288. package/dist/session/session-readiness.js +0 -36
  289. package/dist/telemetry/instrumentation-stall.d.ts +0 -57
  290. package/dist/telemetry/instrumentation-stall.js +0 -103
@@ -160,8 +160,140 @@ function describeNetFilter(p) {
160
160
  const { kind: _kind, count: _count, since: _since, ...filter } = p;
161
161
  return JSON.stringify(filter);
162
162
  }
163
+ /** Same, for a signal: the matcher as applied, with the cardinality and the floor taken out. */
164
+ function describeSignalFilter(p) {
165
+ const { kind: _kind, count: _count, since: _since, ...filter } = p;
166
+ return JSON.stringify(filter);
167
+ }
168
+ /**
169
+ * Exact cardinality, once, for every channel that can count its matches.
170
+ *
171
+ * `net` and `signal` are the same assertion over different evidence — "this happened EXACTLY n
172
+ * times" — and the interesting half is the same on both: an over-count is `decided`, because a
173
+ * window only accumulates and a count cannot come back down, so waiting out the rest of the budget
174
+ * to report a double-fire buys nothing but latency.
175
+ *
176
+ * `noun` names the population in prose ("network call(s)", "signal(s)"), `filter` is the matcher as
177
+ * applied, and `assertion` is the oracle that judged it.
178
+ */
179
+ function evalExactCount(args) {
180
+ const { matched, want, noun, filter, assertion } = args;
181
+ if (matched === want)
182
+ return { pass: true, evidence: { matched } };
183
+ return {
184
+ pass: false,
185
+ ...(matched > want ? { decided: true } : {}),
186
+ failureReason: `expected ${String(want)} ${noun} matching ${filter}, saw ${String(matched)}`,
187
+ observed: `${String(matched)} matching ${noun}`,
188
+ expected: `exactly ${String(want)} matching ${filter}`,
189
+ assertion,
190
+ };
191
+ }
192
+ /**
193
+ * URL suffixes only the DOCUMENT fetches, which the network observer therefore never records.
194
+ *
195
+ * `network.ts` patches `fetch` and `XMLHttpRequest` and nothing else, so `<link rel=icon>`,
196
+ * `<link rel=manifest>`, stylesheets, fonts and `<img src>` are invisible to it. "No matching call"
197
+ * and "that class of call is not observed" are then indistinguishable, and the miss graded
198
+ * `assertion_failed` — a false RED. Reported from the field: an assert over `/favicon.ico`,
199
+ * `/site.webmanifest` and `/apple-touch-icon.png` returned `verified:"no"` while curl showed all
200
+ * three answering 200. A false negative here is worse than an unknown, because an agent that trusts
201
+ * it goes and "fixes" working code.
202
+ *
203
+ * Deliberately NOT here: `.js` and `.json`. Both are routinely fetched via `fetch`/XHR — a module
204
+ * preload and an API call can share a suffix — so downgrading them would hide real misses. This list
205
+ * is only the suffixes for which the document is the sole plausible initiator.
206
+ *
207
+ * This began as the smaller half of #447: it did not make these requests observable, it stopped
208
+ * Reticle claiming they did not happen. The other half, observing them via resource timing, has
209
+ * since landed in the browser SDK, so the two halves are no longer independent and this downgrade is
210
+ * now GATED: when the event stream carries at least one document-initiated record, the observer is
211
+ * demonstrably live and a miss over these suffixes is real evidence, graded a plain failure. Only a
212
+ * page whose stream holds no resource entry at all keeps the honest "cannot tell apart" answer,
213
+ * because there the observer may simply not exist.
214
+ */
215
+ const DOCUMENT_ONLY_SUFFIXES = [
216
+ '.ico',
217
+ '.png',
218
+ '.jpg',
219
+ '.jpeg',
220
+ '.gif',
221
+ '.svg',
222
+ '.webp',
223
+ '.avif',
224
+ '.bmp',
225
+ '.css',
226
+ '.woff',
227
+ '.woff2',
228
+ '.ttf',
229
+ '.otf',
230
+ '.eot',
231
+ '.webmanifest',
232
+ ];
233
+ /**
234
+ * Does this filter target a class of request the observer cannot see?
235
+ *
236
+ * Read off `urlContains` only, and only when the pattern ENDS in one of the suffixes — a filter of
237
+ * `/api/` that happens to contain `.css` somewhere in a query string is still an ordinary XHR
238
+ * target. Query strings and fragments are stripped first, since `favicon.ico?v=2` is the same asset.
239
+ */
240
+ function targetsUnobservedChannel(p) {
241
+ if (p.urlContains === undefined)
242
+ return false;
243
+ const path = (p.urlContains.split('#')[0] ?? '').split('?')[0]?.toLowerCase() ?? '';
244
+ return DOCUMENT_ONLY_SUFFIXES.some((suffix) => path.endsWith(suffix));
245
+ }
246
+ /**
247
+ * Initiator types the browser SDK's resource-timing observer stamps onto document-initiated records
248
+ * (packages/browser/src/observers/network.ts). The patched transports sign themselves `fetch`,
249
+ * `xhr` or `beacon`, so any NET_REQUEST carrying one of these came from a `resource` entry, and its
250
+ * mere presence is proof the PerformanceObserver is alive on this page. Mirrors the wire; keep in
251
+ * sync with the browser package.
252
+ */
253
+ const DOCUMENT_INITIATORS = new Set([
254
+ 'link',
255
+ 'css',
256
+ 'img',
257
+ 'script',
258
+ 'manifest',
259
+ 'other',
260
+ ]);
261
+ /**
262
+ * Did the observer actually report a document-initiated load?
263
+ *
264
+ * This is the gate on the unobserved-channel downgrade above. Once subresource observation landed,
265
+ * a miss over `.ico`/`.css`/`.woff2` is no longer inherently unknowable: if the observer is live it
266
+ * would have seen the favicon load, so seeing none is evidence it never fired. But the observer is
267
+ * opt-in-by-engine, not guaranteed: on a renderer without `PerformanceObserver` (or before any
268
+ * resource entry exists) nothing distinguishes "not requested" from "not visible", and the honest
269
+ * answer stays inconclusive. Liveness is inferred from the same events being judged: one
270
+ * document-initiated record anywhere in the window proves the channel works.
271
+ */
272
+ function observerSawSubresources(events) {
273
+ return events.some((e) => {
274
+ if (e.type !== EventType.NET_REQUEST)
275
+ return false;
276
+ const initiator = str(e.data['initiator']);
277
+ return initiator !== undefined && DOCUMENT_INITIATORS.has(initiator);
278
+ });
279
+ }
280
+ /** The sentence both zero-match branches hand to `inconclusive`. */
281
+ function unobservedChannelReason(p) {
282
+ return (`no call matched ${describeNetFilter(p)}, and no document-initiated load was recorded either, ` +
283
+ `so this page exposed no resource timing to read: Reticle observes fetch and XMLHttpRequest ` +
284
+ `directly, while a request the document initiates (<link rel=icon|manifest|preload>, a ` +
285
+ `stylesheet, a font, <img src>) leaves no record, and that state cannot be told apart from the ` +
286
+ `request having been made. ` +
287
+ `Nothing here says the app is wrong. Check it outside the browser, or assert something the ` +
288
+ `document does not fetch on its own`);
289
+ }
163
290
  export function evalNet(events, p) {
164
291
  const since = p.since ?? 0;
292
+ /**
293
+ * Computed once: the gate every zero-match downgrade below reads. True means the resource-timing
294
+ * observer is demonstrably live in this window, so a miss is evidence rather than blindness.
295
+ */
296
+ const sawSubresources = observerSawSubresources(events);
165
297
  /**
166
298
  * Did any call match everything EXCEPT the body assertion, while carrying no recorded body?
167
299
  *
@@ -171,6 +303,8 @@ export function evalNet(events, p) {
171
303
  * because it is the same pass over the same events.
172
304
  */
173
305
  let matchedButUnrecorded = false;
306
+ /** A call matched url/method but carried NO readable status (document-initiated subresource). */
307
+ let unobservableStatus = false;
174
308
  /**
175
309
  * The response body of a call that matched everything EXCEPT the body assertion, and HAD one.
176
310
  *
@@ -190,8 +324,19 @@ export function evalNet(events, p) {
190
324
  if (p.urlContains !== undefined && !(str(d['url']) ?? '').includes(p.urlContains)) {
191
325
  return false;
192
326
  }
193
- if (p.status !== undefined && num(d['status']) !== p.status)
194
- return false;
327
+ if (p.status !== undefined) {
328
+ const status = num(d['status']);
329
+ if (status === undefined) {
330
+ // Document-initiated subresources (link/css/img/manifest via resource timing) carry no
331
+ // readable status on engines without responseStatus. A failed assertion here would read
332
+ // "your change is broken" and send the caller to fix working code — the exact false
333
+ // negative the oracle exists to prevent. Downgrade to unknown instead.
334
+ unobservableStatus = true;
335
+ return false;
336
+ }
337
+ if (status !== p.status)
338
+ return false;
339
+ }
195
340
  if (p.ok !== undefined && callSucceeded(d) !== p.ok)
196
341
  return false;
197
342
  if (p.bodyContains !== undefined) {
@@ -211,6 +356,18 @@ export function evalNet(events, p) {
211
356
  }
212
357
  return true;
213
358
  });
359
+ if (unobservableStatus && 0 === matches.length) {
360
+ // The url/method matched a document-initiated subresource whose engine could not read a status.
361
+ // "No matching call" would be a lie in both directions: it may have succeeded, it may have 404'd
362
+ // on exactly the path mistake the caller is hunting. Say the truth — not observable here.
363
+ return {
364
+ pass: false,
365
+ inconclusive: `a document-initiated request matching ${describeNetFilter(p)} was observed, but this engine does not expose its status code (resource timing without responseStatus) — assert on the element or route instead, or check the network tab`,
366
+ observed: 'a matching request with no readable status',
367
+ expected: `a status of ${String(p.status)} on ${JSON.stringify(p.urlContains ?? '*')}`,
368
+ assertion: 'net.unobservable-status',
369
+ };
370
+ }
214
371
  if (matchedButUnrecorded && 0 === matches.length) {
215
372
  return {
216
373
  pass: false,
@@ -235,21 +392,37 @@ export function evalNet(events, p) {
235
392
  // useEffect-double-fire / retry-storm regression class, where the request DID fire (presence passes)
236
393
  // but fired the WRONG number of times. Without `count`, the matcher is presence-only (≥1).
237
394
  if (p.count !== undefined) {
238
- return matches.length === p.count
239
- ? { pass: true, evidence: { matched: matches.length } }
240
- : {
395
+ if (matches.length !== p.count &&
396
+ 0 === matches.length &&
397
+ targetsUnobservedChannel(p) &&
398
+ !sawSubresources) {
399
+ return {
241
400
  pass: false,
242
- // Monotonic: more matches than asked for can never become exactly the number asked for.
243
- // This is the double-submit — two writes 59ms apart — so the finding that matters most was
244
- // also the one that took the caller's whole budget to report.
245
- ...(matches.length > p.count ? { decided: true } : {}),
246
- failureReason: `expected ${String(p.count)} network call(s) matching ${describeNetFilter(p)}, saw ${String(matches.length)}`,
247
- observed: `${String(matches.length)} matching network call(s)`,
248
- expected: `exactly ${String(p.count)} matching ${describeNetFilter(p)}`,
401
+ failureReason: unobservedChannelReason(p),
402
+ inconclusive: unobservedChannelReason(p),
249
403
  assertion: 'net.count',
250
404
  };
405
+ }
406
+ return evalExactCount({
407
+ matched: matches.length,
408
+ want: p.count,
409
+ noun: 'network call(s)',
410
+ filter: describeNetFilter(p),
411
+ assertion: 'net.count',
412
+ });
251
413
  }
252
414
  const hit = matches[0];
415
+ if (hit === undefined && targetsUnobservedChannel(p) && !sawSubresources) {
416
+ const reason = unobservedChannelReason(p);
417
+ return {
418
+ pass: false,
419
+ failureReason: reason,
420
+ inconclusive: reason,
421
+ observed: describeObserved('calls', events.filter((e) => e.type === EventType.NET_REQUEST).map(describeCall)),
422
+ expected: `at least one call matching ${describeNetFilter(p)}`,
423
+ assertion: 'net.unobserved-channel',
424
+ };
425
+ }
253
426
  return hit !== undefined
254
427
  ? { pass: true, evidence: hit.data }
255
428
  : {
@@ -296,25 +469,66 @@ export function evalConsole(events, p) {
296
469
  return isErr;
297
470
  return e.type === CONSOLE_LEVEL_TYPE[p.level];
298
471
  });
472
+ // A text match narrows the population to entries whose captured message contains the substring.
473
+ // With `absent: true` this is the whole point: "THIS message did not appear", not "no messages
474
+ // appeared" — the difference between a regression check and a fragile one that any unrelated
475
+ // warning anywhere in the app breaks.
476
+ const wanted = 'contains' in p ? p.contains : undefined;
477
+ // Captured messages are strings (stringifyArgs in the browser observer), but a malformed or
478
+ // foreign event must not crash the evaluator: non-strings stringify defensively, and objects
479
+ // go through JSON.stringify rather than a default toString that would print '[object Object]'.
480
+ const asText = (v) => {
481
+ if ('string' === typeof v)
482
+ return v;
483
+ try {
484
+ return JSON.stringify(v) ?? '';
485
+ }
486
+ catch {
487
+ return '';
488
+ }
489
+ };
490
+ const matching = wanted !== undefined
491
+ ? matches.filter((e) => asText(e.data['message']).includes(wanted))
492
+ : matches;
299
493
  if (true === p.absent) {
300
- return 0 === matches.length
494
+ if (wanted !== undefined && 0 === matching.length && matches.length > 0) {
495
+ // Other entries exist but none carries the substring: exactly the pass an absence-with-match
496
+ // asserts. Name both counts so the caller can tell this from a silent window.
497
+ return {
498
+ pass: true,
499
+ evidence: { absent: true, contains: wanted },
500
+ };
501
+ }
502
+ return 0 === matching.length
301
503
  ? { pass: true, evidence: { absent: true } }
302
504
  : {
303
505
  pass: false,
304
- failureReason: `expected no ${p.level ?? 'console'} entries but found ${String(matches.length)}`,
305
- observed: `${String(matches.length)} ${p.level ?? 'console'} entr${1 === matches.length ? 'y' : 'ies'}`,
306
- expected: `no ${p.level ?? 'console'} entries`,
307
- assertion: 'console.absent',
308
- evidence: matches.map((e) => e.data),
506
+ failureReason: wanted !== undefined
507
+ ? `expected no ${p.level ?? 'console'} entry containing ${JSON.stringify(wanted)} but found ${String(matching.length)}`
508
+ : `expected no ${p.level ?? 'console'} entries but found ${String(matches.length)}`,
509
+ observed: wanted !== undefined
510
+ ? `${String(matching.length)} ${p.level ?? 'console'} entr${1 === matching.length ? 'y' : 'ies'} containing ${JSON.stringify(wanted)}`
511
+ : `${String(matches.length)} ${p.level ?? 'console'} entr${1 === matches.length ? 'y' : 'ies'}`,
512
+ expected: wanted !== undefined
513
+ ? `no ${p.level ?? 'console'} entry containing ${JSON.stringify(wanted)}`
514
+ : `no ${p.level ?? 'console'} entries`,
515
+ assertion: wanted !== undefined ? 'console.absent-contains' : 'console.absent',
516
+ evidence: matching.map((e) => e.data),
309
517
  };
310
518
  }
311
- return matches.length > 0
312
- ? { pass: true, evidence: matches.map((e) => e.data) }
519
+ return matching.length > 0
520
+ ? { pass: true, evidence: matching.map((e) => e.data) }
313
521
  : {
314
522
  pass: false,
315
- failureReason: `no ${p.level ?? 'console'} entries found`,
316
- observed: `no ${p.level ?? 'console'} entries in the window`,
317
- expected: `at least one ${p.level ?? 'console'} entry`,
523
+ failureReason: wanted !== undefined
524
+ ? `no ${p.level ?? 'console'} entry containing ${JSON.stringify(wanted)} found`
525
+ : `no ${p.level ?? 'console'} entries found`,
526
+ observed: wanted !== undefined
527
+ ? `no ${p.level ?? 'console'} entry containing ${JSON.stringify(wanted)} in the window`
528
+ : `no ${p.level ?? 'console'} entries in the window`,
529
+ expected: wanted !== undefined
530
+ ? `at least one ${p.level ?? 'console'} entry containing ${JSON.stringify(wanted)}`
531
+ : `at least one ${p.level ?? 'console'} entry`,
318
532
  assertion: 'console.present',
319
533
  };
320
534
  }
@@ -340,7 +554,7 @@ export function evalAnimation(events, p) {
340
554
  };
341
555
  }
342
556
  export function evalSignal(events, p) {
343
- const hit = events.find((e) => {
557
+ const isMatch = (e) => {
344
558
  if (e.type !== EventType.SIGNAL)
345
559
  return false;
346
560
  if (p.name !== undefined && str(e.data['name']) !== p.name)
@@ -351,7 +565,22 @@ export function evalSignal(events, p) {
351
565
  return false;
352
566
  }
353
567
  return true;
354
- });
568
+ };
569
+ // `count` (exact) turns presence into a cardinality assertion, exactly as it does on `net`. The
570
+ // double-fire is invisible to every state-only oracle — a handler wired twice leaves the store in
571
+ // the right shape and fires the signal twice — and so is the wrong-name fire, where the intended
572
+ // signal fires once beside a mistyped sibling and a presence check cannot say which is which.
573
+ // Counting only what the MATCHER matched is what separates them. Omit = presence (≥1).
574
+ if (p.count !== undefined) {
575
+ return evalExactCount({
576
+ matched: events.filter(isMatch).length,
577
+ want: p.count,
578
+ noun: 'signal(s)',
579
+ filter: describeSignalFilter(p),
580
+ assertion: 'signal.count',
581
+ });
582
+ }
583
+ const hit = events.find(isMatch);
355
584
  if (hit !== undefined)
356
585
  return { pass: true, evidence: hit.data };
357
586
  // Near-miss: show signals that fired with the same name (so the agent sees the real data).
@@ -14,10 +14,4 @@
14
14
  */
15
15
  import { z } from 'zod';
16
16
  import { PredicateSchema } from './predicate-eval.js';
17
- /**
18
- * Parse a predicate, or throw an Error whose message is a sentence.
19
- *
20
- * Deliberately still THROWS: every call site already handles a throw, and turning this into a
21
- * result type would mean touching three handlers to gain nothing the message does not already say.
22
- */
23
17
  export declare function parsePredicate(input: unknown): z.infer<typeof PredicateSchema>;
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { z } from 'zod';
16
16
  import { PredicateKind } from '@reticlehq/core';
17
- import { PredicateSchema, predicateFieldsFor } from './predicate-eval.js';
17
+ import { PredicateSchema, predicateFieldsFor, predicateNestedFieldsFor } from './predicate-eval.js';
18
18
  /**
19
19
  * One valid call PER KIND, because an example of another kind answers a question nobody asked.
20
20
  *
@@ -67,7 +67,35 @@ function describeIssue(issue) {
67
67
  * Deliberately still THROWS: every call site already handles a throw, and turning this into a
68
68
  * result type would mean touching three handlers to gain nothing the message does not already say.
69
69
  */
70
+ /**
71
+ * The placeholder `verify_next` puts in the `until` it suggests, sent back unchanged.
72
+ *
73
+ * The nudge hands the agent a ready-made `act_and_wait` call with `ref` and `action` filled in from
74
+ * the act that dispatched, and leaves the consequence blank on purpose: naming it is the one part
75
+ * only the agent can know, and guessing would be Reticle inventing the assertion.
76
+ *
77
+ * Sent verbatim it parses as a perfectly valid text predicate that simply never matches, so the
78
+ * verdict came back `verified:"no"` / "the declared consequence did not hold" — blaming the app for
79
+ * a placeholder the agent forgot to replace, and sending it to hunt a defect that does not exist.
80
+ * Measured on a live app before this guard: a confident, wrong, actionable-looking answer, which is
81
+ * the exact failure the rest of this file exists to avoid.
82
+ */
83
+ const UNFILLED_PLACEHOLDER = /^<name the consequence/i;
84
+ function placeholderValue(input) {
85
+ if ('object' !== typeof input || null === input)
86
+ return undefined;
87
+ const value = input['value'];
88
+ return 'string' === typeof value && UNFILLED_PLACEHOLDER.test(value) ? value : undefined;
89
+ }
70
90
  export function parsePredicate(input) {
91
+ const unfilled = placeholderValue(input);
92
+ if (unfilled !== undefined) {
93
+ throw new Error(`that predicate still carries the placeholder from verify_next ("${unfilled}"). Nothing ran, ` +
94
+ 'and no verdict was produced — which is deliberate: as written it would have failed and ' +
95
+ 'blamed the app for a value you had not filled in yet. Replace it with the consequence ' +
96
+ 'this action actually causes, and prefer one the action CHANGES: a signal, a request, a ' +
97
+ 'route, or store state. Text on screen that was already there proves nothing.');
98
+ }
71
99
  const parsed = PredicateSchema.safeParse(input);
72
100
  if (parsed.success)
73
101
  return parsed.data;
@@ -78,7 +106,7 @@ export function parsePredicate(input) {
78
106
  // back is how the zod array became unreadable in the first place.
79
107
  const issues = parsed.error.issues.slice(0, 3).map(describeIssue).join('; ');
80
108
  throw new Error(`that predicate did not parse (kind "${kind}"): ${issues}. Nothing ran — the predicate was ` +
81
- `not evaluated, so no verdict was produced. ${accepted(kind)} ` +
109
+ `not evaluated, so no verdict was produced. ${accepted(kind, parsed.error.issues)} ` +
82
110
  `A valid ${kind} predicate looks like: ${exampleFor(kind)}`);
83
111
  }
84
112
  /**
@@ -88,9 +116,27 @@ export function parsePredicate(input) {
88
116
  * round trip on the one call path that produces verdicts. Naming the accepted fields — or, when the
89
117
  * kind itself is the mistake, the accepted kinds — makes the retry informed instead.
90
118
  */
91
- function accepted(kind) {
119
+ function accepted(kind, issues) {
92
120
  const fields = predicateFieldsFor(kind);
93
- if (0 < fields.length)
94
- return `${kind} accepts: ${fields.join(', ')}.`;
121
+ if (0 < fields.length) {
122
+ // A field whose value is an object is the one an agent cannot guess from its name alone, so
123
+ // expand it in the same breath rather than making the shape a second round trip.
124
+ //
125
+ // Only the field the rejection actually points at, though. Expanding every object-valued field
126
+ // of the kind makes the sentence longer the more the schema grows, and buries the one clause
127
+ // that answers the question asked — the same argument this file already makes for showing one
128
+ // example per kind instead of a generic one. `element` is the only kind with an object-valued
129
+ // field today, so the two agree except when the mistake is somewhere else entirely, which is
130
+ // the case pinned in the tests.
131
+ const all = predicateNestedFieldsFor(kind);
132
+ const blamed = new Set(issues
133
+ .map((issue) => issue.path[0])
134
+ .filter((field) => 'string' === typeof field));
135
+ const nested = Object.entries(all)
136
+ .filter(([field]) => blamed.has(field))
137
+ .map(([field, keys]) => ` ${field} accepts: ${keys.join(', ')}.`)
138
+ .join('');
139
+ return `${kind} accepts: ${fields.join(', ')}.${nested}`;
140
+ }
95
141
  return `"${kind}" is not a predicate kind — use one of: ${Object.values(PredicateKind).join(', ')}.`;
96
142
  }
@@ -38,6 +38,12 @@ export type Predicate = {
38
38
  } | {
39
39
  kind: typeof PredicateKind.CONSOLE;
40
40
  level?: string;
41
+ /**
42
+ * A substring the captured message must contain. With `absent: true` this flips the meaning
43
+ * from "no console entries at all" to "THIS message did not appear" — the assertion people
44
+ * actually write regression checks for (deprecation warnings, no-op handler notices).
45
+ */
46
+ contains?: string;
41
47
  absent?: boolean;
42
48
  since?: number;
43
49
  } | {
@@ -50,6 +56,11 @@ export type Predicate = {
50
56
  kind: typeof PredicateKind.SIGNAL;
51
57
  name?: string;
52
58
  dataMatches?: Record<string, unknown>;
59
+ /**
60
+ * Exact number of matching signals in the window — the same cardinality assertion `net`
61
+ * carries, on the channel the app itself speaks. Omit for presence (≥1).
62
+ */
63
+ count?: number;
53
64
  since?: number;
54
65
  } | {
55
66
  kind: typeof PredicateKind.STATE;
@@ -97,3 +108,54 @@ export declare const PredicateSchema: z.ZodType<Predicate>;
97
108
  * wall. Empty for a kind that is not in the union.
98
109
  */
99
110
  export declare function predicateFieldsFor(kind: string): readonly string[];
111
+ /**
112
+ * Is this tool parameter ACTUALLY the predicate union?
113
+ *
114
+ * Not a name check. `until` is overloaded on this surface — the act/assert family means a predicate
115
+ * by it, while reticle_observe / _network / _console mean a NUMBER, an upper cursor bound — so any
116
+ * caller keying on the word would treat a numeric parameter as a predicate and describe it to the
117
+ * agent as an object. Keying on the schema cannot make that mistake.
118
+ *
119
+ * Lives beside the schema because two callers now ask the question: the lean tool surface, which
120
+ * compacts these parameters, and `reticle_tools`, which spells their grammar out on request. Two
121
+ * spellings of "is this a predicate" is one chance for the surface and the grammar to disagree.
122
+ */
123
+ export declare function isPredicateParam(schema: z.ZodTypeAny): boolean;
124
+ /**
125
+ * Every kind's fields, in one block an agent can write a predicate from.
126
+ *
127
+ * The tool surface advertises the KIND list and points at `reticle_tools` for the fields, because
128
+ * inlining the 12-variant recursive union in the declared JSON Schema costs thousands of characters
129
+ * per predicate parameter, re-sent every turn, to describe a grammar most calls use one variant of.
130
+ * That trade is right — but the pointer has to land somewhere, and it landed on a parameter
131
+ * description reading "same shape as reticle_assert". So the grammar was reachable from nothing, and
132
+ * an agent that could not find `route`'s fields fell back to a `text` check where a route check was
133
+ * meant: the weaker oracle, which is the expensive half of an undiscoverable grammar.
134
+ *
135
+ * Derived from the schema, like `predicateFieldsFor` and for the same reason: a hand-written grammar
136
+ * that drifts is worse than none, because the agent trusts it and retries into the same wall.
137
+ */
138
+ export declare function predicateGrammar(): Readonly<Record<string, string>>;
139
+ /**
140
+ * The keys of a field that is an object, once its wrappers are peeled. Empty for anything else.
141
+ *
142
+ * Exported so the wrapper handling is asserted against THIS function rather than through the
143
+ * rendered sentence. Reached only that way, a wrapper it fails to peel returns `[]`, the old
144
+ * message prints unchanged, and nothing reddens — a regression with no symptom.
145
+ *
146
+ * No top-level predicate field is currently declared behind a wrapper, so the real schema exercises
147
+ * none of these paths; that is exactly why they are worth a case each.
148
+ */
149
+ export declare function nestedKeysOf(schema: z.ZodTypeAny | undefined): readonly string[];
150
+ /**
151
+ * One level into the fields that are themselves objects — `{ query: ['by', 'value', ...] }`.
152
+ *
153
+ * Naming the top-level fields alone leaves `element` unguessable: `query` is an object with its own
154
+ * required shape, and a plain CSS string works in `reticle_snapshot.scope` and `reticle_query.scope`,
155
+ * so assuming it works here is the natural guess. The rejection is the only place that inconsistency
156
+ * can be explained.
157
+ *
158
+ * One level only. Derived from the schema for the same reason `predicateFieldsFor` is: a stale field
159
+ * list is worse than none, because the agent trusts it and retries into the same wall.
160
+ */
161
+ export declare function predicateNestedFieldsFor(kind: string): Readonly<Record<string, readonly string[]>>;