@reticlehq/server 2.8.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (292) hide show
  1. package/SKILL.md +130 -813
  2. package/dist/bridge/bridge.d.ts +23 -0
  3. package/dist/bridge/bridge.js +80 -2
  4. package/dist/bridge/bridge.test-harness.d.ts +5 -0
  5. package/dist/bridge/bridge.test-harness.js +6 -4
  6. package/dist/capsule/causal-summary.d.ts +16 -1
  7. package/dist/capsule/causal-summary.js +2 -1
  8. package/dist/cli/cli-doctor.js +41 -13
  9. package/dist/cli/cli-flow-commands.js +8 -8
  10. package/dist/cli/cli-launch.d.ts +0 -17
  11. package/dist/cli/cli-launch.js +0 -28
  12. package/dist/cli/cli-port.d.ts +24 -0
  13. package/dist/cli/cli-port.js +66 -35
  14. package/dist/cli/cli-verify.d.ts +21 -0
  15. package/dist/cli/cli-verify.js +45 -1
  16. package/dist/cli/config-discovery.d.ts +35 -0
  17. package/dist/cli/config-discovery.js +163 -0
  18. package/dist/cli/doctor-daemon-line.js +4 -1
  19. package/dist/cli/doctor-project-line.d.ts +37 -0
  20. package/dist/cli/doctor-project-line.js +35 -0
  21. package/dist/cli/doctor-rows.d.ts +51 -0
  22. package/dist/cli/doctor-rows.js +54 -0
  23. package/dist/cli/doctor-sessions-line.js +6 -3
  24. package/dist/cli/drive-attach.d.ts +99 -0
  25. package/dist/cli/drive-attach.js +203 -0
  26. package/dist/cli/drive-command.d.ts +13 -0
  27. package/dist/cli/drive-command.js +103 -0
  28. package/dist/cli/open-note.d.ts +20 -0
  29. package/dist/cli/open-note.js +37 -0
  30. package/dist/cli/status-next-action.d.ts +37 -0
  31. package/dist/cli/status-next-action.js +61 -0
  32. package/dist/cli.js +65 -70
  33. package/dist/events/contradictions.d.ts +32 -0
  34. package/dist/events/contradictions.js +9 -5
  35. package/dist/events/declared.d.ts +45 -0
  36. package/dist/events/declared.js +73 -0
  37. package/dist/events/echo-mismatch.d.ts +8 -2
  38. package/dist/events/echo-mismatch.js +33 -5
  39. package/dist/events/event-filters.d.ts +1 -1
  40. package/dist/events/event-filters.js +7 -4
  41. package/dist/events/predicate-eval.d.ts +17 -91
  42. package/dist/events/predicate-eval.js +85 -326
  43. package/dist/events/predicate-parse.js +31 -3
  44. package/dist/events/predicate-route.d.ts +32 -0
  45. package/dist/events/predicate-route.js +102 -0
  46. package/dist/events/predicate-schema.d.ts +99 -0
  47. package/dist/events/predicate-schema.js +309 -0
  48. package/dist/events/predicate.d.ts +8 -0
  49. package/dist/events/predicate.js +127 -5
  50. package/dist/flows/annotate-tools.js +3 -0
  51. package/dist/flows/annotate.js +18 -4
  52. package/dist/flows/coverage.d.ts +38 -5
  53. package/dist/flows/coverage.js +30 -4
  54. package/dist/flows/decision.d.ts +15 -0
  55. package/dist/flows/decision.js +1 -1
  56. package/dist/flows/flow-replay-run.js +8 -1
  57. package/dist/flows/flow-tools.js +9 -1
  58. package/dist/flows/replay.js +3 -0
  59. package/dist/honesty/accepted-write.d.ts +3 -0
  60. package/dist/honesty/accepted-write.js +8 -3
  61. package/dist/honesty/already-true.js +15 -0
  62. package/dist/honesty/blind-spots.js +4 -0
  63. package/dist/honesty/honesty.d.ts +11 -0
  64. package/dist/honesty/honesty.js +1 -0
  65. package/dist/honesty/uncaptured-bodies.d.ts +4 -2
  66. package/dist/honesty/uncaptured-bodies.js +9 -2
  67. package/dist/honesty/unread-outcome.d.ts +8 -0
  68. package/dist/honesty/unread-outcome.js +36 -9
  69. package/dist/honesty/unsettled.d.ts +9 -0
  70. package/dist/honesty/unsettled.js +7 -3
  71. package/dist/honesty/verified.d.ts +29 -3
  72. package/dist/honesty/verified.js +51 -8
  73. package/dist/http-server.d.ts +19 -0
  74. package/dist/http-server.js +202 -4
  75. package/dist/impact/impact-recorder.d.ts +23 -0
  76. package/dist/impact/impact-recorder.js +76 -0
  77. package/dist/impact/impact-store.d.ts +47 -0
  78. package/dist/impact/impact-store.js +171 -0
  79. package/dist/index.d.ts +7 -0
  80. package/dist/index.js +44 -10
  81. package/dist/init/agent-rules.d.ts +20 -0
  82. package/dist/init/agent-rules.js +36 -0
  83. package/dist/init/astro-patch.js +27 -1
  84. package/dist/init/confirm.d.ts +21 -3
  85. package/dist/init/confirm.js +32 -5
  86. package/dist/init/connect-steps.d.ts +2 -0
  87. package/dist/init/connect-steps.js +50 -0
  88. package/dist/init/csp-check.d.ts +38 -0
  89. package/dist/init/csp-check.js +92 -0
  90. package/dist/init/csp-doctor.d.ts +25 -0
  91. package/dist/init/csp-doctor.js +49 -0
  92. package/dist/init/cursor.js +6 -9
  93. package/dist/init/detect.d.ts +8 -0
  94. package/dist/init/detect.js +33 -3
  95. package/dist/init/mcp-clients.d.ts +1 -0
  96. package/dist/init/mcp-clients.js +51 -7
  97. package/dist/init/mcp.d.ts +15 -0
  98. package/dist/init/mcp.js +19 -0
  99. package/dist/init/plan-framework.d.ts +10 -0
  100. package/dist/init/plan-framework.js +25 -1
  101. package/dist/init/plan.d.ts +7 -2
  102. package/dist/init/plan.js +66 -30
  103. package/dist/init/run.js +11 -5
  104. package/dist/init/slash-command.d.ts +1 -1
  105. package/dist/init/slash-command.js +12 -4
  106. package/dist/init/snippets.d.ts +1 -1
  107. package/dist/init/snippets.js +35 -7
  108. package/dist/init/vite-config.js +24 -2
  109. package/dist/input/network-mock-tools.js +8 -1
  110. package/dist/input/scroll-find.d.ts +6 -0
  111. package/dist/input/scroll-find.js +17 -2
  112. package/dist/input/scroll-tools.js +4 -0
  113. package/dist/journal/attach-journal.d.ts +9 -1
  114. package/dist/journal/attach-journal.js +13 -2
  115. package/dist/journal/journal-query.d.ts +13 -0
  116. package/dist/journal/journal-query.js +28 -3
  117. package/dist/journal/workspace-gitignore.d.ts +10 -0
  118. package/dist/journal/workspace-gitignore.js +59 -0
  119. package/dist/mcp/mcp-proxy.d.ts +5 -66
  120. package/dist/mcp/mcp-proxy.js +180 -163
  121. package/dist/mcp/mcp.d.ts +10 -1
  122. package/dist/mcp/mcp.js +47 -27
  123. package/dist/mcp/proxy-handshake.d.ts +82 -1
  124. package/dist/mcp/proxy-handshake.js +109 -2
  125. package/dist/mcp/proxy-lifecycle.d.ts +10 -1
  126. package/dist/mcp/proxy-lifecycle.js +11 -2
  127. package/dist/mcp/proxy-log.d.ts +63 -0
  128. package/dist/mcp/proxy-log.js +132 -0
  129. package/dist/mcp/server-instructions.d.ts +17 -0
  130. package/dist/mcp/server-instructions.js +56 -0
  131. package/dist/mcp/shared-params.d.ts +27 -0
  132. package/dist/mcp/shared-params.js +36 -0
  133. package/dist/project/reticle-dir.d.ts +2 -0
  134. package/dist/project/reticle-dir.js +1 -0
  135. package/dist/session/connection-memory.d.ts +59 -0
  136. package/dist/session/connection-memory.js +108 -0
  137. package/dist/session/dev-command.d.ts +26 -0
  138. package/dist/session/dev-command.js +89 -0
  139. package/dist/session/dev-server-probe.d.ts +68 -0
  140. package/dist/session/dev-server-probe.js +102 -7
  141. package/dist/session/last-act.d.ts +26 -0
  142. package/dist/session/last-act.js +35 -1
  143. package/dist/session/live-control-tools.js +42 -5
  144. package/dist/session/no-session-diagnosis.d.ts +48 -2
  145. package/dist/session/no-session-diagnosis.js +176 -20
  146. package/dist/session/no-session-next-action.d.ts +46 -0
  147. package/dist/session/no-session-next-action.js +85 -0
  148. package/dist/session/no-session-watch.d.ts +40 -1
  149. package/dist/session/no-session-watch.js +165 -11
  150. package/dist/session/pending-commands.d.ts +12 -0
  151. package/dist/session/pending-commands.js +13 -1
  152. package/dist/session/session-manager.d.ts +4 -0
  153. package/dist/session/session-manager.js +35 -5
  154. package/dist/session/session.d.ts +10 -1
  155. package/dist/session/session.js +146 -5
  156. package/dist/telemetry/app-instrumented.d.ts +8 -0
  157. package/dist/telemetry/app-instrumented.js +10 -0
  158. package/dist/telemetry/bug-found.d.ts +18 -0
  159. package/dist/telemetry/bug-found.js +14 -0
  160. package/dist/telemetry/daemon-telemetry.js +24 -0
  161. package/dist/telemetry/install-source.d.ts +20 -0
  162. package/dist/telemetry/install-source.js +34 -0
  163. package/dist/telemetry/instrumentation-stall.d.ts +50 -0
  164. package/dist/telemetry/instrumentation-stall.js +90 -0
  165. package/dist/telemetry/session-metrics.d.ts +8 -0
  166. package/dist/telemetry/session-metrics.js +10 -0
  167. package/dist/telemetry/telemetry.d.ts +3 -1
  168. package/dist/telemetry/telemetry.js +13 -2
  169. package/dist/tools/act-tools.js +166 -29
  170. package/dist/tools/assert-verdict.js +32 -7
  171. package/dist/tools/browser-tools.js +10 -0
  172. package/dist/tools/dynamic-tools.js +16 -7
  173. package/dist/tools/error-recovery.js +9 -5
  174. package/dist/tools/invoke-tool.js +20 -3
  175. package/dist/tools/lease-hint.d.ts +30 -8
  176. package/dist/tools/lease-hint.js +65 -14
  177. package/dist/tools/lease-tools.d.ts +11 -0
  178. package/dist/tools/lease-tools.js +141 -96
  179. package/dist/tools/observe-tools.js +9 -3
  180. package/dist/tools/react-grace.d.ts +4 -0
  181. package/dist/tools/react-grace.js +6 -3
  182. package/dist/tools/read-tools.js +47 -1
  183. package/dist/tools/resolve-target.d.ts +33 -0
  184. package/dist/tools/resolve-target.js +53 -0
  185. package/dist/tools/settle-in-flight.d.ts +15 -0
  186. package/dist/tools/settle-in-flight.js +42 -2
  187. package/dist/tools/snapshot-delta.d.ts +3 -0
  188. package/dist/tools/snapshot-delta.js +67 -6
  189. package/dist/tools/tool-kit.d.ts +8 -0
  190. package/dist/tools/tool-surface.d.ts +60 -0
  191. package/dist/tools/tool-surface.js +85 -19
  192. package/dist/tools/tools.js +45 -4
  193. package/dist/tools/unadvertised-help.js +9 -6
  194. package/docs/README.md +1 -2
  195. package/docs/actions.mdx +41 -8
  196. package/docs/agent-cheatsheet.md +4 -2
  197. package/docs/architecture.md +6 -0
  198. package/docs/benchmarks.md +60 -12
  199. package/docs/best-practices.mdx +11 -6
  200. package/docs/capabilities.mdx +201 -54
  201. package/docs/cli/affected.mdx +6 -4
  202. package/docs/cli/capsules.mdx +9 -8
  203. package/docs/cli/cloud.mdx +30 -4
  204. package/docs/cli/doctor.mdx +41 -8
  205. package/docs/cli/drive.mdx +34 -15
  206. package/docs/cli/gate.mdx +6 -6
  207. package/docs/cli/help.mdx +8 -6
  208. package/docs/cli/hunt.mdx +18 -2
  209. package/docs/cli/identify.mdx +16 -2
  210. package/docs/cli/init.mdx +35 -8
  211. package/docs/cli/kill.mdx +5 -5
  212. package/docs/cli/license.mdx +1 -1
  213. package/docs/cli/open.mdx +6 -0
  214. package/docs/cli/restart.mdx +6 -6
  215. package/docs/cli/serve.mdx +10 -3
  216. package/docs/cli/status.mdx +29 -5
  217. package/docs/cli/stop.mdx +8 -0
  218. package/docs/cli/verify.mdx +20 -1
  219. package/docs/cli/version.mdx +1 -1
  220. package/docs/cli/watch.mdx +9 -2
  221. package/docs/cli.mdx +44 -22
  222. package/docs/debugging.md +3 -1
  223. package/docs/deploy-checks.md +30 -1
  224. package/docs/desktop.mdx +276 -80
  225. package/docs/docs.json +13 -12
  226. package/docs/enterprise.md +2 -0
  227. package/docs/faq.mdx +49 -35
  228. package/docs/fixtures.md +2 -0
  229. package/docs/flows.md +3 -1
  230. package/docs/for-agents.md +3 -3
  231. package/docs/frameworks.mdx +46 -7
  232. package/docs/gate-plan.md +4 -2
  233. package/docs/gates.md +7 -5
  234. package/docs/getting-started.md +53 -12
  235. package/docs/human-control.md +2 -0
  236. package/docs/index.mdx +43 -1
  237. package/docs/install-agentic.mdx +13 -4
  238. package/docs/install-manual.mdx +6 -4
  239. package/docs/instrumentation.mdx +7 -1
  240. package/docs/integration-patterns.md +3 -1
  241. package/docs/local-registry.md +4 -2
  242. package/docs/multi-agent-testing.md +3 -1
  243. package/docs/packages/babel-plugin.mdx +15 -6
  244. package/docs/packages/browser.mdx +2 -2
  245. package/docs/packages/core.mdx +26 -16
  246. package/docs/packages/electron.mdx +5 -7
  247. package/docs/packages/eslint-plugin.mdx +5 -3
  248. package/docs/packages/next.mdx +3 -5
  249. package/docs/packages/react.mdx +3 -3
  250. package/docs/packages/server.mdx +2 -2
  251. package/docs/packages/tauri.mdx +6 -6
  252. package/docs/packages/test.mdx +18 -7
  253. package/docs/packages/vite-plugin.mdx +4 -6
  254. package/docs/packages.mdx +14 -13
  255. package/docs/platform-integration.md +2 -0
  256. package/docs/predicates.mdx +76 -7
  257. package/docs/quickstart.mdx +51 -5
  258. package/docs/recipes.mdx +62 -22
  259. package/docs/skill-file.mdx +3 -1
  260. package/docs/state-management.mdx +2 -0
  261. package/docs/system-map.md +3 -1
  262. package/docs/telemetry-contract.md +16 -5
  263. package/docs/telemetry-events.mdx +63 -18
  264. package/docs/telemetry.md +8 -5
  265. package/docs/testing.md +35 -1
  266. package/docs/token-efficiency.md +8 -2
  267. package/docs/tools-act-and-wait.mdx +64 -14
  268. package/docs/tools-act-sequence.mdx +34 -17
  269. package/docs/tools-act.mdx +24 -11
  270. package/docs/tools-assert.mdx +69 -10
  271. package/docs/tools-console.mdx +11 -7
  272. package/docs/tools-inspect.mdx +11 -8
  273. package/docs/tools-navigate.mdx +41 -5
  274. package/docs/tools-network.mdx +42 -9
  275. package/docs/tools-observe.mdx +66 -10
  276. package/docs/tools-overview.mdx +26 -23
  277. package/docs/tools-query.mdx +52 -8
  278. package/docs/tools-session-and-feedback.mdx +30 -9
  279. package/docs/tools-sessions.mdx +39 -10
  280. package/docs/tools-snapshot.mdx +57 -3
  281. package/docs/tools-state.mdx +24 -1
  282. package/docs/tools-tools-and-run.mdx +40 -9
  283. package/docs/tools-wait-for.mdx +34 -15
  284. package/docs/troubleshooting.mdx +80 -10
  285. package/docs/usage.md +43 -21
  286. package/docs/vs-chrome-devtools-mcp.mdx +41 -0
  287. package/docs/vs-playwright-mcp.mdx +42 -2
  288. package/docs/vs-screenshots.mdx +42 -1
  289. package/docs/what-is-reticle.mdx +1 -1
  290. package/docs/why-reticle.mdx +3 -1
  291. package/package.json +5 -5
  292. package/docs/desktop-apps.md +0 -265
@@ -1,91 +1,6 @@
1
- import { ElementState, PredicateKind, type ElementDescriptor, type ElementQuery, type ReticleEvent } from '@reticlehq/core';
2
- import { z } from 'zod';
3
- export type Predicate = {
4
- kind: typeof PredicateKind.ELEMENT;
5
- query: ElementQuery;
6
- state?: ElementState;
7
- absent?: boolean;
8
- } | {
9
- kind: typeof PredicateKind.TEXT;
10
- contains: string;
11
- visible?: boolean;
12
- absent?: boolean;
13
- } | {
14
- kind: typeof PredicateKind.NET;
15
- method?: string;
16
- urlContains?: string;
17
- status?: number;
18
- /** Did the call succeed? The honest field for IPC, which has no status code. */
19
- ok?: boolean;
20
- since?: number;
21
- count?: number;
22
- } | {
23
- kind: typeof PredicateKind.ROUTE;
24
- pathname?: string;
25
- contains?: string;
26
- since?: number;
27
- } | {
28
- kind: typeof PredicateKind.CONSOLE;
29
- level?: string;
30
- absent?: boolean;
31
- since?: number;
32
- } | {
33
- kind: typeof PredicateKind.ANIMATION;
34
- name?: string;
35
- target?: string;
36
- completed?: boolean;
37
- since?: number;
38
- } | {
39
- kind: typeof PredicateKind.SIGNAL;
40
- name?: string;
41
- dataMatches?: Record<string, unknown>;
42
- since?: number;
43
- } | {
44
- kind: typeof PredicateKind.STATE;
45
- store?: string;
46
- path: string;
47
- equals?: unknown;
48
- } | {
49
- kind: typeof PredicateKind.SETTLED;
50
- quietMs?: number;
51
- } | {
52
- kind: typeof PredicateKind.ALL_OF;
53
- predicates: Predicate[];
54
- } | {
55
- kind: typeof PredicateKind.ANY_OF;
56
- predicates: Predicate[];
57
- } | {
58
- kind: typeof PredicateKind.NOT;
59
- predicate: Predicate;
60
- };
61
- export interface ResidualQueryChecks {
62
- /** Dropped fields this side CAN check, as [field, wanted value] pairs. */
63
- checks: [string, string][];
64
- /** Dropped fields with no descriptor to check them against — refuse rather than ignore. */
65
- unusable: string[];
66
- }
67
- /**
68
- * Split a query's dropped fields into the ones the server can still enforce and the ones it cannot.
69
- *
70
- * The alternative — refuse every dropped field — breaks calls that work today and that our own
71
- * cheatsheet advertises (`{ role: "button", text: "Save" }`), and breaks them into no verdict at all.
72
- * Enforcing what we can and refusing only the rest keeps those calls working AS WRITTEN, which is the
73
- * outcome the caller was already assuming.
74
- */
75
- export declare function residualQueryChecks(query: ElementQuery): ResidualQueryChecks;
76
- /** Does this element satisfy every field the locator dropped? */
77
- export declare function satisfiesResiduals(element: ElementDescriptor, checks: readonly [string, string][]): boolean;
78
- /** How the element's own reading of a dropped field should be REPORTED back on a failure. */
79
- export declare function describeResidual(element: ElementDescriptor, field: string): string;
80
- export declare const PredicateSchema: z.ZodType<Predicate>;
81
- /**
82
- * The fields a given predicate kind accepts, read off the schema itself.
83
- *
84
- * Derived rather than listed so the two can never disagree: a rejection message that names a stale
85
- * field set is worse than one that names none, because the agent trusts it and retries into the same
86
- * wall. Empty for a kind that is not in the union.
87
- */
88
- export declare function predicateFieldsFor(kind: string): readonly string[];
1
+ import { PredicateKind, type ReticleEvent } from '@reticlehq/core';
2
+ import type { Predicate } from './predicate-schema.js';
3
+ export * from './predicate-schema.js';
89
4
  export interface EvalResult {
90
5
  pass: boolean;
91
6
  /**
@@ -104,6 +19,19 @@ export interface EvalResult {
104
19
  * not a defect in the user's app, and reporting it as one puts agent mistakes into the bug count.
105
20
  */
106
21
  inconclusive?: string;
22
+ /**
23
+ * No later event in this window can change this answer, so waiting out the rest of the budget
24
+ * buys nothing.
25
+ *
26
+ * Set ONLY where that is provable, which is rarer than it looks. "The toast never appeared" is not
27
+ * decided — it is only knowable when the budget ends, and ending early there would manufacture the
28
+ * false negative the budget exists to prevent. Exact cardinality IS decided once exceeded, because
29
+ * a window only accumulates matches and a count cannot come back down.
30
+ *
31
+ * A scheduling fact, never a verdict: `pass` already says what the answer is, and this only says
32
+ * that it is final.
33
+ */
34
+ decided?: boolean;
107
35
  evidence?: unknown;
108
36
  failureReason?: string;
109
37
  /**
@@ -132,6 +60,7 @@ export interface EvalResult {
132
60
  expected?: string;
133
61
  assertion?: string;
134
62
  }
63
+ export declare function str(value: unknown): string | undefined;
135
64
  /**
136
65
  * Match one value against a pattern. Supports `*` (present), strict equality, and operators:
137
66
  * `{$gte,$lte,$gt,$lt}` (numbers), `{$contains}` (array membership or substring), `{$length}`.
@@ -140,9 +69,6 @@ export declare function matchValue(got: unknown, want: unknown): boolean;
140
69
  export declare function evalNet(events: ReticleEvent[], p: Extract<Predicate, {
141
70
  kind: typeof PredicateKind.NET;
142
71
  }>): EvalResult;
143
- export declare function evalRoute(events: ReticleEvent[], p: Extract<Predicate, {
144
- kind: typeof PredicateKind.ROUTE;
145
- }>): EvalResult;
146
72
  export declare function evalConsole(events: ReticleEvent[], p: Extract<Predicate, {
147
73
  kind: typeof PredicateKind.CONSOLE;
148
74
  }>): EvalResult;
@@ -1,289 +1,10 @@
1
- import { ElementQuerySchema, ElementState, EventType, PredicateKind, QueryBy, StreamDirection, isDevToolingUrl, } from '@reticlehq/core';
2
- import { z } from 'zod';
1
+ import { EventType, PredicateKind, StreamDirection, isDevToolingUrl, } from '@reticlehq/core';
3
2
  import { describeObserved } from './observed-in-window.js';
4
- /**
5
- * Spellings an agent plausibly reaches for, mapped to the real field.
6
- *
7
- * `route` spells its field `pathname` while `state`, in the same union, spells its `path`. Every one
8
- * of these was silently DROPPED before, and because these kinds have all-optional fields, dropping
9
- * the only key supplied left a predicate that asserts nothing and passes on anything.
10
- */
11
- const PREDICATE_ALIASES = {
12
- // `text`/`value` on a `text` predicate: the kind is called "text", so `text:` is the first thing
13
- // anyone writes for it, and `value` follows from the element query's own `value` field. Both were
14
- // hard rejections, and a rejected predicate produces NO verdict at all — the drive ends with
15
- // nothing rather than with a failure, which is the worst outcome of the three.
16
- [PredicateKind.TEXT]: { text: 'contains', value: 'contains' },
17
- // `urlContains`/`url` reported from the field: an agent that had just written
18
- // `net { urlContains }` applied the same word to `route`, which spells it `contains`, and got
19
- // `unrecognized_keys` with no list of what would have worked. The parallel it assumed is a fair
20
- // one — route's `contains` matches the WHOLE route (path + query + fragment), so "the URL
21
- // contains this" is precisely what it does — and asserting a redirect after login is the single
22
- // most common thing an agent reaches for here.
23
- [PredicateKind.ROUTE]: { path: 'pathname', urlContains: 'contains', url: 'contains' },
24
- [PredicateKind.NET]: { url: 'urlContains' },
25
- [PredicateKind.SIGNAL]: { data: 'dataMatches' },
26
- };
27
- /**
28
- * Element-query fields an agent writes FLAT on an `element` predicate instead of nested under
29
- * `query`. `reticle_query` takes exactly these at the top level, so an agent that has just located
30
- * something writes the same words again when it asserts on it — and got `query: Required` plus an
31
- * `unknown field` list for its trouble. Lifting them is unambiguous: they have no other meaning on
32
- * this kind.
33
- */
34
- const ELEMENT_QUERY_FIELDS = [
35
- 'by',
36
- 'value',
37
- 'role',
38
- 'name',
39
- 'text',
40
- 'label',
41
- 'placeholder',
42
- 'testid',
43
- 'alt',
44
- 'component',
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
- }
138
- /** Rename known aliases before parse; an explicit canonical key always wins. */
139
- function applyPredicateAliases(input) {
140
- if (typeof input !== 'object' || null === input || Array.isArray(input))
141
- return input;
142
- const obj = input;
143
- const kind = 'string' === typeof obj['kind'] ? obj['kind'] : '';
144
- const aliases = PREDICATE_ALIASES[kind];
145
- let out = obj;
146
- if (aliases !== undefined) {
147
- out = { ...obj };
148
- for (const [from, to] of Object.entries(aliases)) {
149
- if (out[from] === undefined)
150
- continue;
151
- if (out[to] === undefined)
152
- out[to] = out[from];
153
- delete out[from];
154
- }
155
- }
156
- return PredicateKind.ELEMENT === kind ? liftElementQuery(out) : out;
157
- }
158
- /**
159
- * Fold flat query fields into `query`. An explicit `query` wins outright — a caller that supplied
160
- * both told us which one it meant, and merging the two would invent a locator neither side wrote.
161
- */
162
- function liftElementQuery(obj) {
163
- const loose = ELEMENT_QUERY_FIELDS.filter((field) => obj[field] !== undefined);
164
- if (0 === loose.length)
165
- return obj;
166
- const out = { ...obj };
167
- const query = {};
168
- for (const field of loose) {
169
- query[field] = out[field];
170
- delete out[field];
171
- }
172
- if (out['query'] === undefined)
173
- out['query'] = query;
174
- return out;
175
- }
176
- /**
177
- * Strict on every branch. A key that is nobody's spelling is now a schema error naming it, instead of
178
- * a stripped field and a green — see predicate-strict.test.ts for the MCP session that found this.
179
- *
180
- * Built on demand rather than at module scope: the `allOf`/`anyOf`/`not` branches reference
181
- * `PredicateSchema` itself, which does not exist yet while this module is initialising. Calling it
182
- * after init is what makes the union introspectable — see `predicateFieldsFor`.
183
- */
184
- function predicateUnion() {
185
- return z.discriminatedUnion('kind', [
186
- z
187
- .object({
188
- kind: z.literal(PredicateKind.ELEMENT),
189
- query: ElementQuerySchema,
190
- state: z.nativeEnum(ElementState).optional(),
191
- absent: z.boolean().optional(),
192
- })
193
- .strict(),
194
- z
195
- .object({
196
- kind: z.literal(PredicateKind.TEXT),
197
- contains: z.string(),
198
- visible: z.boolean().optional(),
199
- absent: z.boolean().optional(),
200
- })
201
- .strict(),
202
- z
203
- .object({
204
- kind: z.literal(PredicateKind.NET),
205
- method: z.string().optional(),
206
- urlContains: z.string().optional(),
207
- status: z.number().optional(),
208
- ok: z.boolean().optional(),
209
- since: z.number().optional(),
210
- count: z.number().int().nonnegative().optional(),
211
- })
212
- .strict(),
213
- z
214
- .object({
215
- kind: z.literal(PredicateKind.ROUTE),
216
- pathname: z.string().optional(),
217
- contains: z.string().optional(),
218
- since: z.number().optional(),
219
- })
220
- .strict(),
221
- z
222
- .object({
223
- kind: z.literal(PredicateKind.CONSOLE),
224
- level: z.string().optional(),
225
- absent: z.boolean().optional(),
226
- since: z.number().optional(),
227
- })
228
- .strict(),
229
- z
230
- .object({
231
- kind: z.literal(PredicateKind.ANIMATION),
232
- name: z.string().optional(),
233
- target: z.string().optional(),
234
- completed: z.boolean().optional(),
235
- since: z.number().optional(),
236
- })
237
- .strict(),
238
- z
239
- .object({
240
- kind: z.literal(PredicateKind.SIGNAL),
241
- name: z.string().optional(),
242
- dataMatches: z.record(z.unknown()).optional(),
243
- since: z.number().optional(),
244
- })
245
- .strict(),
246
- z
247
- .object({
248
- kind: z.literal(PredicateKind.STATE),
249
- store: z.string().optional(),
250
- path: z.string(),
251
- equals: z.unknown().optional(),
252
- })
253
- .strict(),
254
- z
255
- .object({
256
- kind: z.literal(PredicateKind.SETTLED),
257
- quietMs: z.number().positive().optional(),
258
- })
259
- .strict(),
260
- z
261
- .object({ kind: z.literal(PredicateKind.ALL_OF), predicates: z.array(PredicateSchema) })
262
- .strict(),
263
- z
264
- .object({ kind: z.literal(PredicateKind.ANY_OF), predicates: z.array(PredicateSchema) })
265
- .strict(),
266
- z.object({ kind: z.literal(PredicateKind.NOT), predicate: PredicateSchema }).strict(),
267
- ]);
268
- }
269
- export const PredicateSchema = z.lazy(() => z.preprocess(applyPredicateAliases, predicateUnion()));
270
- /**
271
- * The fields a given predicate kind accepts, read off the schema itself.
272
- *
273
- * Derived rather than listed so the two can never disagree: a rejection message that names a stale
274
- * field set is worse than one that names none, because the agent trusts it and retries into the same
275
- * wall. Empty for a kind that is not in the union.
276
- */
277
- export function predicateFieldsFor(kind) {
278
- for (const option of predicateUnion().options) {
279
- const literal = option.shape['kind'];
280
- if (literal instanceof z.ZodLiteral && literal.value === kind) {
281
- return Object.keys(option.shape).filter((field) => 'kind' !== field);
282
- }
283
- }
284
- return [];
285
- }
286
- function str(value) {
3
+ // The predicate SHAPE — the discriminated union, its aliases and its zod schema — lives in
4
+ // predicate-schema.ts. Re-exported here so every existing importer of this module is unaffected:
5
+ // the two halves are one public surface, split only because the file outgrew the size backstop.
6
+ export * from './predicate-schema.js';
7
+ export function str(value) {
287
8
  return 'string' === typeof value ? value : undefined;
288
9
  }
289
10
  function num(value) {
@@ -419,8 +140,46 @@ function describeCall(e) {
419
140
  const status = num(e.data['status']);
420
141
  return status === undefined ? head : `${head} → ${String(status)}`;
421
142
  }
143
+ /**
144
+ * How much of a response body a failure may quote. Enough to see the value that differed on the
145
+ * bodies this field is used against (a JSON answer), and not a whole payload in every verdict.
146
+ */
147
+ const MAX_BODY_IN_FAILURE = 200;
148
+ /** Enough of a body to see what differed, without paying for a whole payload in the verdict. */
149
+ function clipBody(body) {
150
+ return body.length <= MAX_BODY_IN_FAILURE ? body : `${body.slice(0, MAX_BODY_IN_FAILURE)}…`;
151
+ }
152
+ /**
153
+ * The filter as APPLIED, every field of it.
154
+ *
155
+ * The count failure used to print `{method, urlContains, status}` while `ok` and `bodyContains` were
156
+ * applied too, so the caller was shown a predicate it had not written and told nothing matched it.
157
+ * A printed filter that is narrower than the real one is worse than none: it is believed.
158
+ */
159
+ function describeNetFilter(p) {
160
+ const { kind: _kind, count: _count, since: _since, ...filter } = p;
161
+ return JSON.stringify(filter);
162
+ }
422
163
  export function evalNet(events, p) {
423
164
  const since = p.since ?? 0;
165
+ /**
166
+ * Did any call match everything EXCEPT the body assertion, while carrying no recorded body?
167
+ *
168
+ * Bodies are opt-in, so without them a body predicate can never hold — and "no call matched" would
169
+ * send the caller to check the url and the method, which are both fine, instead of to the one
170
+ * setting that makes the assertion possible. Tracked while filtering rather than recomputed after,
171
+ * because it is the same pass over the same events.
172
+ */
173
+ let matchedButUnrecorded = false;
174
+ /**
175
+ * The response body of a call that matched everything EXCEPT the body assertion, and HAD one.
176
+ *
177
+ * Reported by the first field user of `bodyContains`: a body mismatch was formatted as
178
+ * "expected 1 network call(s) matching {method, urlContains, status}, saw 0" — the body assertion
179
+ * was applied but not printed — so the verdict read "the request never fired" and sent them hunting
180
+ * a UI wiring bug that did not exist, when the defect was the value the server answered with.
181
+ */
182
+ let bodyMismatch;
424
183
  const matches = events.filter((e) => {
425
184
  if (e.type !== EventType.NET_REQUEST || e.t < since)
426
185
  return false;
@@ -435,8 +194,43 @@ export function evalNet(events, p) {
435
194
  return false;
436
195
  if (p.ok !== undefined && callSucceeded(d) !== p.ok)
437
196
  return false;
197
+ if (p.bodyContains !== undefined) {
198
+ // The RESPONSE body only, and this is the whole point of the field. Searching the request too
199
+ // would let `bodyContains: "1187.01"` pass on the very defect it exists to catch: the app SENT
200
+ // that number, so it is in the request whatever the server then did with it. The server's answer
201
+ // is the one channel a UI cannot fake.
202
+ const response = str(d['responseBody']);
203
+ if (response === undefined) {
204
+ matchedButUnrecorded = true;
205
+ return false;
206
+ }
207
+ if (!response.includes(p.bodyContains)) {
208
+ bodyMismatch ??= response;
209
+ return false;
210
+ }
211
+ }
438
212
  return true;
439
213
  });
214
+ if (matchedButUnrecorded && 0 === matches.length) {
215
+ return {
216
+ pass: false,
217
+ failureReason: `a call matched but its body was not recorded, so \`bodyContains\` could not be checked — enable it where the app calls connect(): reticle({ captureNetworkBodies: true })`,
218
+ observed: 'a matching call with no recorded body',
219
+ expected: `a body containing ${JSON.stringify(p.bodyContains)}`,
220
+ assertion: 'net.bodyContains',
221
+ };
222
+ }
223
+ if (bodyMismatch !== undefined && 0 === matches.length) {
224
+ // The call is there and its body is there; only the VALUE differs. Counting it as zero matches
225
+ // points at the wiring, which is the one place the defect is not.
226
+ return {
227
+ pass: false,
228
+ failureReason: `a call matching ${describeNetFilter(p)} was made and answered ${JSON.stringify(clipBody(bodyMismatch))}, which does not contain ${JSON.stringify(p.bodyContains)} — the request fired, the response value is what differed`,
229
+ observed: `response body ${JSON.stringify(clipBody(bodyMismatch))}`,
230
+ expected: `a response body containing ${JSON.stringify(p.bodyContains)}`,
231
+ assertion: 'net.bodyContains',
232
+ };
233
+ }
440
234
  // `count` (exact) turns presence into a cardinality assertion — catches the double-submit /
441
235
  // useEffect-double-fire / retry-storm regression class, where the request DID fire (presence passes)
442
236
  // but fired the WRONG number of times. Without `count`, the matcher is presence-only (≥1).
@@ -445,9 +239,13 @@ export function evalNet(events, p) {
445
239
  ? { pass: true, evidence: { matched: matches.length } }
446
240
  : {
447
241
  pass: false,
448
- failureReason: `expected ${String(p.count)} network call(s) matching ${JSON.stringify({ method: p.method, urlContains: p.urlContains, status: p.status })}, saw ${String(matches.length)}`,
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)}`,
449
247
  observed: `${String(matches.length)} matching network call(s)`,
450
- expected: `exactly ${String(p.count)} matching ${JSON.stringify({ method: p.method, urlContains: p.urlContains, status: p.status })}`,
248
+ expected: `exactly ${String(p.count)} matching ${describeNetFilter(p)}`,
451
249
  assertion: 'net.count',
452
250
  };
453
251
  }
@@ -464,45 +262,6 @@ export function evalNet(events, p) {
464
262
  assertion: 'net.present',
465
263
  };
466
264
  }
467
- export function evalRoute(events, p) {
468
- const routes = events.filter((e) => e.type === EventType.ROUTE_CHANGE);
469
- const last = routes.at(-1);
470
- if (last === undefined) {
471
- return {
472
- pass: false,
473
- failureReason: 'no route change observed',
474
- observed: 'no route change in the window',
475
- expected: `a route change to ${p.pathname ?? p.contains ?? 'any route'}`,
476
- assertion: 'route.changed',
477
- };
478
- }
479
- const pathname = str(last.data['pathname']) ?? str(last.data['to']) ?? '';
480
- if (p.pathname !== undefined && pathname !== p.pathname) {
481
- return {
482
- pass: false,
483
- failureReason: `route is '${pathname}', expected '${p.pathname}'`,
484
- observed: `route '${pathname}'`,
485
- expected: `route '${p.pathname}'`,
486
- assertion: 'route.pathname',
487
- };
488
- }
489
- // `contains` matches the WHOLE route — path + query + fragment — while `pathname` above stays an
490
- // exact path match. A hash router keeps the entire route in the fragment, so matching pathname
491
- // alone made `contains` unsatisfiable for every HashRouter app; that is the standard router for a
492
- // packaged Electron/Tauri renderer, where an absolute pushState would rewrite the URL to a
493
- // nonexistent file.
494
- const fullRoute = `${pathname}${str(last.data['search']) ?? ''}${str(last.data['hash']) ?? ''}`;
495
- if (p.contains !== undefined && !fullRoute.includes(p.contains)) {
496
- return {
497
- pass: false,
498
- failureReason: `route '${fullRoute}' does not contain '${p.contains}'`,
499
- observed: `route '${fullRoute}'`,
500
- expected: `a route containing '${p.contains}'`,
501
- assertion: 'route.contains',
502
- };
503
- }
504
- return { pass: true, evidence: last.data };
505
- }
506
265
  /** The only console levels Reticle instruments (console.info/debug/trace are NOT patched). */
507
266
  const CONSOLE_LEVEL_TYPE = {
508
267
  log: EventType.CONSOLE_LOG,
@@ -15,8 +15,36 @@
15
15
  import { z } from 'zod';
16
16
  import { PredicateKind } from '@reticlehq/core';
17
17
  import { PredicateSchema, predicateFieldsFor } from './predicate-eval.js';
18
- /** A valid call, short enough to sit inside an error without becoming the error. */
19
- const EXAMPLE = '{ kind: "signal", name: "todos:loaded" }';
18
+ /**
19
+ * One valid call PER KIND, because an example of another kind answers a question nobody asked.
20
+ *
21
+ * This was a single `signal` example shown for every rejection. Watched on a real drive: an agent got
22
+ * the `element` shape wrong, was shown a `signal` example, got `element` wrong again, and was shown
23
+ * the same `signal` example. A rejected predicate produces no verdict at all, so each of those was a
24
+ * round trip that ended with nothing — and the one field an agent most needs (`element`'s nested
25
+ * `query`) is exactly what a flat example cannot convey.
26
+ *
27
+ * Short by design: an example that grows past a line stops being read.
28
+ */
29
+ const EXAMPLES = {
30
+ [PredicateKind.ELEMENT]: '{ kind: "element", query: { role: "button", name: "Save" } }',
31
+ [PredicateKind.TEXT]: '{ kind: "text", contains: "Saved" }',
32
+ [PredicateKind.NET]: '{ kind: "net", method: "POST", urlContains: "/api/save", status: 200 }',
33
+ [PredicateKind.ROUTE]: '{ kind: "route", pathname: "/dashboard" }',
34
+ [PredicateKind.CONSOLE]: '{ kind: "console", level: "error", absent: true }',
35
+ [PredicateKind.ANIMATION]: '{ kind: "animation", name: "slide-in", completed: true }',
36
+ [PredicateKind.SIGNAL]: '{ kind: "signal", name: "todos:loaded" }',
37
+ [PredicateKind.STATE]: '{ kind: "state", path: "cart.total", equals: 0 }',
38
+ [PredicateKind.SETTLED]: '{ kind: "settled" }',
39
+ [PredicateKind.ALL_OF]: '{ kind: "allOf", predicates: [{ kind: "text", contains: "Saved" }, { kind: "console", level: "error", absent: true }] }',
40
+ [PredicateKind.ANY_OF]: '{ kind: "anyOf", predicates: [{ kind: "text", contains: "Saved" }, { kind: "text", contains: "Updated" }] }',
41
+ [PredicateKind.NOT]: '{ kind: "not", predicate: { kind: "text", contains: "Error" } }',
42
+ };
43
+ /** The fallback, for when the KIND itself is the mistake and there is no shape to demonstrate. */
44
+ const GENERIC_EXAMPLE = '{ kind: "text", contains: "Saved" }';
45
+ function exampleFor(kind) {
46
+ return EXAMPLES[kind] ?? GENERIC_EXAMPLE;
47
+ }
20
48
  /** `path: ["net","urlContains"]` → `net.urlContains`; an empty path means the object itself. */
21
49
  function pathOf(issue) {
22
50
  return 0 === issue.path.length ? 'the predicate' : issue.path.map(String).join('.');
@@ -51,7 +79,7 @@ export function parsePredicate(input) {
51
79
  const issues = parsed.error.issues.slice(0, 3).map(describeIssue).join('; ');
52
80
  throw new Error(`that predicate did not parse (kind "${kind}"): ${issues}. Nothing ran — the predicate was ` +
53
81
  `not evaluated, so no verdict was produced. ${accepted(kind)} ` +
54
- `A valid call looks like: ${EXAMPLE}`);
82
+ `A valid ${kind} predicate looks like: ${exampleFor(kind)}`);
55
83
  }
56
84
  /**
57
85
  * What WOULD have worked, in the same breath as what did not.
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The `route` oracle. Split out of predicate-eval.ts, which was at the file-size backstop.
3
+ *
4
+ * Route is the one predicate with TWO sources of truth — a navigation inside the window and where the
5
+ * app is right now — and keeping that reconciliation in one place is what makes it readable.
6
+ */
7
+ import { PredicateKind, type ReticleEvent } from '@reticlehq/core';
8
+ import { type EvalResult, type Predicate } from './predicate-eval.js';
9
+ /**
10
+ * Which fact answered a `route` predicate: a navigation inside the window, or where the app is now.
11
+ *
12
+ * Reported on every route result because the two are different claims. "It changed to /login" proves
13
+ * a redirect; "it was already /login" does not, and an agent asserting a post-login redirect has to
14
+ * be able to tell them apart. Silently conflating them would trade one wrong verdict for another.
15
+ */
16
+ export declare const RouteDecidedBy: {
17
+ readonly CHANGE: "route-change";
18
+ readonly CURRENT: "current-route";
19
+ };
20
+ export declare function evalRoute(events: ReticleEvent[], p: Extract<Predicate, {
21
+ kind: typeof PredicateKind.ROUTE;
22
+ }>,
23
+ /**
24
+ * Where the app is RIGHT NOW, if the caller can say.
25
+ *
26
+ * Without it a route predicate could only ever be answered by a navigation inside the window, so
27
+ * "did the session survive a reload?" — which by definition produces no route change — and "we
28
+ * landed on /login" after a completed navigate were both a guaranteed false red, contradicted by
29
+ * `reticle_snapshot({ mode: 'status' }).route` one call later. Optional so a caller with no session
30
+ * (a replayed window) keeps the old answer instead of inventing a route.
31
+ */
32
+ currentUrl?: string): EvalResult;