@reticlehq/server 2.5.0 → 2.7.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 (182) hide show
  1. package/SKILL.md +226 -26
  2. package/dist/bridge/bridge.d.ts +18 -1
  3. package/dist/bridge/bridge.js +59 -6
  4. package/dist/bridge/bridge.test-harness.d.ts +8 -0
  5. package/dist/bridge/bridge.test-harness.js +9 -1
  6. package/dist/cli/chromium-hint.d.ts +40 -0
  7. package/dist/cli/chromium-hint.js +75 -0
  8. package/dist/cli/cli-doctor.d.ts +0 -5
  9. package/dist/cli/cli-doctor.js +74 -14
  10. package/dist/cli/cli-launch.d.ts +1 -0
  11. package/dist/cli/cli-launch.js +4 -1
  12. package/dist/cli/cli-parse-feedback.d.ts +31 -0
  13. package/dist/cli/cli-parse-feedback.js +56 -0
  14. package/dist/cli/cli-parse.d.ts +5 -9
  15. package/dist/cli/cli-parse.js +7 -23
  16. package/dist/cli/cli-port.js +20 -1
  17. package/dist/cli/doctor-daemon-line.d.ts +37 -0
  18. package/dist/cli/doctor-daemon-line.js +44 -0
  19. package/dist/cli/port-holder.d.ts +43 -0
  20. package/dist/cli/port-holder.js +99 -0
  21. package/dist/cli.js +170 -19
  22. package/dist/daemon/daemon-resilience.d.ts +24 -0
  23. package/dist/daemon/daemon-resilience.js +105 -17
  24. package/dist/daemon/daemon.js +26 -1
  25. package/dist/daemon/heartbeat.d.ts +115 -0
  26. package/dist/daemon/heartbeat.js +141 -0
  27. package/dist/daemon/idle-shutdown.d.ts +13 -5
  28. package/dist/daemon/idle-shutdown.js +14 -6
  29. package/dist/daemon/loopback-alias.d.ts +32 -0
  30. package/dist/daemon/loopback-alias.js +80 -0
  31. package/dist/daemon/port-presence.d.ts +76 -0
  32. package/dist/daemon/port-presence.js +93 -0
  33. package/dist/daemon/state-dir.d.ts +7 -0
  34. package/dist/daemon/state-dir.js +59 -0
  35. package/dist/daemon/unclean-predecessor.d.ts +23 -0
  36. package/dist/daemon/unclean-predecessor.js +27 -0
  37. package/dist/events/predicate-eval.d.ts +17 -3
  38. package/dist/events/predicate-eval.js +183 -96
  39. package/dist/events/predicate-parse.d.ts +23 -0
  40. package/dist/events/predicate-parse.js +68 -0
  41. package/dist/events/predicate.d.ts +5 -0
  42. package/dist/events/predicate.js +17 -3
  43. package/dist/events/ring-buffer.d.ts +8 -0
  44. package/dist/events/ring-buffer.js +35 -17
  45. package/dist/flows/flow-replay.d.ts +1 -1
  46. package/dist/flows/flow-replay.js +19 -2
  47. package/dist/flows/flow-step-runners.js +2 -3
  48. package/dist/flows/flow-tools.js +10 -14
  49. package/dist/flows/role-drift-reason.d.ts +20 -0
  50. package/dist/flows/role-drift-reason.js +39 -0
  51. package/dist/flows/suite-flakes.d.ts +33 -0
  52. package/dist/flows/suite-flakes.js +32 -0
  53. package/dist/honesty/blind-spots.d.ts +19 -0
  54. package/dist/honesty/blind-spots.js +33 -0
  55. package/dist/honesty/honesty.d.ts +11 -0
  56. package/dist/honesty/honesty.js +9 -1
  57. package/dist/honesty/verified.d.ts +10 -1
  58. package/dist/honesty/verified.js +42 -3
  59. package/dist/http-server.js +20 -1
  60. package/dist/index.d.ts +1 -0
  61. package/dist/index.js +36 -3
  62. package/dist/init/agent-rules.d.ts +18 -0
  63. package/dist/init/agent-rules.js +28 -5
  64. package/dist/init/cra.d.ts +13 -0
  65. package/dist/init/cra.js +16 -0
  66. package/dist/init/detect.d.ts +14 -0
  67. package/dist/init/detect.js +23 -0
  68. package/dist/init/mcp-clients.d.ts +81 -0
  69. package/dist/init/mcp-clients.js +248 -0
  70. package/dist/init/node-io.js +8 -2
  71. package/dist/init/non-js-project.d.ts +27 -0
  72. package/dist/init/non-js-project.js +60 -0
  73. package/dist/init/plan-framework.d.ts +9 -0
  74. package/dist/init/plan-framework.js +55 -3
  75. package/dist/init/plan.d.ts +14 -0
  76. package/dist/init/plan.js +93 -12
  77. package/dist/init/run.d.ts +18 -1
  78. package/dist/init/run.js +169 -23
  79. package/dist/init/snippets.d.ts +20 -1
  80. package/dist/init/snippets.js +71 -6
  81. package/dist/init/windows-quote.d.ts +38 -0
  82. package/dist/init/windows-quote.js +70 -0
  83. package/dist/mcp/mcp-outage.d.ts +1 -0
  84. package/dist/mcp/mcp-outage.js +8 -1
  85. package/dist/mcp/mcp-proxy.d.ts +11 -1
  86. package/dist/mcp/mcp-proxy.js +39 -9
  87. package/dist/mcp/mcp.js +64 -2
  88. package/dist/mcp/proxy-lifecycle.d.ts +19 -0
  89. package/dist/mcp/proxy-lifecycle.js +21 -0
  90. package/dist/pool/browser-pool.d.ts +9 -0
  91. package/dist/pool/browser-pool.js +38 -9
  92. package/dist/pool/playwright-launcher.js +10 -2
  93. package/dist/session/attachment-history.d.ts +46 -0
  94. package/dist/session/attachment-history.js +65 -0
  95. package/dist/session/live-control-tools.js +39 -2
  96. package/dist/session/live-control.d.ts +7 -9
  97. package/dist/session/live-control.js +34 -1
  98. package/dist/session/no-session-diagnosis.d.ts +11 -4
  99. package/dist/session/no-session-diagnosis.js +95 -14
  100. package/dist/session/no-session-watch.d.ts +3 -1
  101. package/dist/session/no-session-watch.js +10 -2
  102. package/dist/session/pending-commands.js +1 -0
  103. package/dist/session/review-store.d.ts +19 -0
  104. package/dist/session/review-store.js +34 -5
  105. package/dist/session/session-info.d.ts +17 -1
  106. package/dist/session/session-info.js +5 -1
  107. package/dist/session/session-manager.d.ts +12 -0
  108. package/dist/session/session-manager.js +83 -3
  109. package/dist/session/session.d.ts +9 -0
  110. package/dist/session/session.js +20 -0
  111. package/dist/status-payload.d.ts +13 -1
  112. package/dist/status-payload.js +6 -1
  113. package/dist/telemetry/app-instrumented.d.ts +37 -0
  114. package/dist/telemetry/app-instrumented.js +54 -0
  115. package/dist/telemetry/crash-cause.d.ts +47 -0
  116. package/dist/telemetry/crash-cause.js +104 -0
  117. package/dist/telemetry/daemon-telemetry.d.ts +25 -7
  118. package/dist/telemetry/daemon-telemetry.js +32 -13
  119. package/dist/telemetry/error-class.d.ts +26 -0
  120. package/dist/telemetry/error-class.js +52 -0
  121. package/dist/telemetry/feedback-cli.d.ts +7 -2
  122. package/dist/telemetry/feedback-cli.js +49 -5
  123. package/dist/telemetry/feedback-context.d.ts +1 -6
  124. package/dist/telemetry/feedback-context.js +64 -4
  125. package/dist/telemetry/git-facts.d.ts +7 -0
  126. package/dist/telemetry/git-facts.js +4 -4
  127. package/dist/telemetry/project-profile.js +2 -1
  128. package/dist/telemetry/session-metrics.d.ts +45 -3
  129. package/dist/telemetry/session-metrics.js +247 -9
  130. package/dist/telemetry/telemetry.d.ts +21 -1
  131. package/dist/telemetry/telemetry.js +34 -9
  132. package/dist/telemetry/verification-of.js +26 -1
  133. package/dist/tools/act-tools.js +63 -14
  134. package/dist/tools/assert-verdict.d.ts +7 -1
  135. package/dist/tools/assert-verdict.js +22 -3
  136. package/dist/tools/browser-tools.js +8 -2
  137. package/dist/tools/contract-tools.d.ts +0 -5
  138. package/dist/tools/contract-tools.js +26 -0
  139. package/dist/tools/dynamic-tools.js +6 -1
  140. package/dist/tools/error-recovery.d.ts +14 -0
  141. package/dist/tools/error-recovery.js +89 -8
  142. package/dist/tools/feedback-invite.d.ts +48 -0
  143. package/dist/tools/feedback-invite.js +71 -0
  144. package/dist/tools/invoke-tool.d.ts +0 -6
  145. package/dist/tools/invoke-tool.js +76 -7
  146. package/dist/tools/navigate-arrival.d.ts +26 -0
  147. package/dist/tools/navigate-arrival.js +60 -0
  148. package/dist/tools/navigate-result.d.ts +15 -1
  149. package/dist/tools/navigate-result.js +17 -10
  150. package/dist/tools/observe-tools.js +22 -4
  151. package/dist/tools/real-input-attempt.js +10 -5
  152. package/dist/tools/tool-kit.d.ts +60 -5
  153. package/dist/tools/tool-kit.js +55 -24
  154. package/dist/tools/tool-surface.js +13 -0
  155. package/dist/tools/tools.js +22 -0
  156. package/dist/visual/capture-cleanup.d.ts +16 -0
  157. package/dist/visual/capture-cleanup.js +39 -0
  158. package/dist/visual/visual-tools.d.ts +18 -0
  159. package/dist/visual/visual-tools.js +24 -5
  160. package/docs/README.md +55 -0
  161. package/docs/agent-cheatsheet.md +15 -3
  162. package/docs/benchmarks.md +1 -1
  163. package/docs/fixtures-dispatch-receiver.yml +76 -0
  164. package/docs/fixtures.md +17 -0
  165. package/docs/gate-plan.md +98 -0
  166. package/docs/gates.md +117 -0
  167. package/docs/local-registry.md +1 -1
  168. package/docs/matrix/2.5.0/claude-code-machine.json +19 -0
  169. package/docs/matrix/2.5.0/codex-machine.json +20 -0
  170. package/docs/matrix/2.5.0/cursor-machine.json +22 -0
  171. package/docs/matrix/2.5.0/gemini-machine.json +22 -0
  172. package/docs/matrix/2.5.0/opencode-machine.json +22 -0
  173. package/docs/matrix/2.5.0/vscode-machine.json +22 -0
  174. package/docs/matrix/2.5.0/windsurf-machine.json +22 -0
  175. package/docs/matrix/MATRIX.md +26 -0
  176. package/docs/matrix/README.md +59 -0
  177. package/docs/mint.json +1 -1
  178. package/docs/system-map.md +152 -0
  179. package/docs/telemetry-contract.md +15 -3
  180. package/docs/telemetry.md +16 -1
  181. package/docs/token-efficiency.md +2 -2
  182. package/package.json +2 -2
@@ -0,0 +1,7 @@
1
+ /**
2
+ * A sentence naming the problem, or `undefined` when the directory is usable.
3
+ *
4
+ * A directory that does not exist yet is NOT a problem: the daemon creates it, and the creation
5
+ * failing is caught by the same probe on the parent path.
6
+ */
7
+ export declare function stateDirProblem(dir: string): string | undefined;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Can Reticle write the directory it keeps everything in?
3
+ *
4
+ * `~/.reticle` holds the pid files, the daemon logs, the telemetry id and outbox, and every saved
5
+ * flow. A daemon that cannot write it does not start — and until now said nothing useful about why.
6
+ *
7
+ * Measured on 2.6.0 with `chmod 555 ~/.reticle`:
8
+ *
9
+ * the daemon did not come up on :4415 — nothing is listening on :4415 — no daemon is running
10
+ * see /…/.reticle/daemon-4415.log
11
+ *
12
+ * The first line restates the symptom. The second points at a log **inside the unwritable
13
+ * directory**, so the one diagnostic offered cannot exist. Someone hitting this — a locked-down home
14
+ * directory, a container with a read-only mount, a managed corporate profile — has nothing to go on.
15
+ *
16
+ * Deliberately a PROBE, not a permission-bit read: `stat` says what the mode claims, and an ACL, a
17
+ * read-only mount, SELinux or a full disk all produce a writable-looking mode and a failing write.
18
+ * The only honest test of "can I write here" is writing.
19
+ */
20
+ import { mkdirSync, existsSync, writeFileSync, unlinkSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ /** Probe filename. Distinctive so a leftover is obviously ours, and unlinked immediately either way. */
23
+ const PROBE = '.reticle-write-probe';
24
+ /**
25
+ * A sentence naming the problem, or `undefined` when the directory is usable.
26
+ *
27
+ * A directory that does not exist yet is NOT a problem: the daemon creates it, and the creation
28
+ * failing is caught by the same probe on the parent path.
29
+ */
30
+ export function stateDirProblem(dir) {
31
+ if (!existsSync(dir)) {
32
+ // Not yet created is the ordinary first-run state. Only report if we cannot create it.
33
+ try {
34
+ mkdirSync(dir, { recursive: true });
35
+ }
36
+ catch {
37
+ return (`Reticle cannot create its state directory at ${dir}. Everything it keeps — the daemon ` +
38
+ 'log, the pid file, saved flows — lives there, so the daemon cannot start. Check the ' +
39
+ 'permissions on the parent directory, or point Reticle elsewhere with HOME.');
40
+ }
41
+ return undefined;
42
+ }
43
+ const probe = join(dir, PROBE);
44
+ try {
45
+ writeFileSync(probe, '', 'utf8');
46
+ }
47
+ catch {
48
+ return (`Reticle cannot write to its state directory at ${dir}. Everything it keeps — the daemon ` +
49
+ 'log, the pid file, saved flows — lives there, so the daemon cannot start. Make it writable ' +
50
+ '(`chmod u+w`), or point Reticle elsewhere with HOME.');
51
+ }
52
+ try {
53
+ unlinkSync(probe);
54
+ }
55
+ catch {
56
+ /* wrote but could not clean up: the directory IS writable, which is the question asked */
57
+ }
58
+ return undefined;
59
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The pid of a daemon that died without exiting, observed at the next start.
3
+ *
4
+ * `DaemonExitReason` covers every departure that goes through Node — signal, idle, and the
5
+ * load-bearing `unknown` for a stray exit or an uncaught throw. It cannot cover SIGKILL, an OOM
6
+ * kill, or a lost machine: no handler runs, so the log's last word about that daemon is whatever it
7
+ * happened to be doing. From the report (#123):
8
+ *
9
+ * 10:49:05 mcp_daemon_started port:4400 <- pid 2884
10
+ * 10:51:29 mcp_daemon_started port:4400 pid:4900 <- a SECOND daemon binds the SAME port
11
+ *
12
+ * pid 2884 died with no exit event at all, and the only evidence was that a second daemon could bind
13
+ * a port the first one held — something a reader has to notice and interpret.
14
+ *
15
+ * The next startup is the only place that death is observable, because it is the only moment
16
+ * anything looks at the pidfile the dead process left behind. A tidy shutdown unlinks its own, so
17
+ * finding one whose owner is gone IS the signal.
18
+ *
19
+ * Pure, and deliberately conservative: an owner that is still ALIVE is a port conflict, not a death,
20
+ * and accusing a live sibling of dying would be a new false claim in the log this exists to make
21
+ * honest.
22
+ */
23
+ export declare function uncleanPredecessor(owner: number | null, alive: boolean, self: number): number | null;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The pid of a daemon that died without exiting, observed at the next start.
3
+ *
4
+ * `DaemonExitReason` covers every departure that goes through Node — signal, idle, and the
5
+ * load-bearing `unknown` for a stray exit or an uncaught throw. It cannot cover SIGKILL, an OOM
6
+ * kill, or a lost machine: no handler runs, so the log's last word about that daemon is whatever it
7
+ * happened to be doing. From the report (#123):
8
+ *
9
+ * 10:49:05 mcp_daemon_started port:4400 <- pid 2884
10
+ * 10:51:29 mcp_daemon_started port:4400 pid:4900 <- a SECOND daemon binds the SAME port
11
+ *
12
+ * pid 2884 died with no exit event at all, and the only evidence was that a second daemon could bind
13
+ * a port the first one held — something a reader has to notice and interpret.
14
+ *
15
+ * The next startup is the only place that death is observable, because it is the only moment
16
+ * anything looks at the pidfile the dead process left behind. A tidy shutdown unlinks its own, so
17
+ * finding one whose owner is gone IS the signal.
18
+ *
19
+ * Pure, and deliberately conservative: an owner that is still ALIVE is a port conflict, not a death,
20
+ * and accusing a live sibling of dying would be a new false claim in the log this exists to make
21
+ * honest.
22
+ */
23
+ export function uncleanPredecessor(owner, alive, self) {
24
+ if (null === owner || owner === self || alive)
25
+ return null;
26
+ return owner;
27
+ }
@@ -58,11 +58,15 @@ export type Predicate = {
58
58
  kind: typeof PredicateKind.NOT;
59
59
  predicate: Predicate;
60
60
  };
61
+ export declare const PredicateSchema: z.ZodType<Predicate>;
61
62
  /**
62
- * Strict on every branch. A key that is nobody's spelling is now a schema error naming it, instead of
63
- * a stripped field and a green — see predicate-strict.test.ts for the MCP session that found this.
63
+ * The fields a given predicate kind accepts, read off the schema itself.
64
+ *
65
+ * Derived rather than listed so the two can never disagree: a rejection message that names a stale
66
+ * field set is worse than one that names none, because the agent trusts it and retries into the same
67
+ * wall. Empty for a kind that is not in the union.
64
68
  */
65
- export declare const PredicateSchema: z.ZodType<Predicate>;
69
+ export declare function predicateFieldsFor(kind: string): readonly string[];
66
70
  export interface EvalResult {
67
71
  pass: boolean;
68
72
  /**
@@ -83,6 +87,16 @@ export interface EvalResult {
83
87
  inconclusive?: string;
84
88
  evidence?: unknown;
85
89
  failureReason?: string;
90
+ /**
91
+ * The wait ended because the TAB went away, not because the app did anything observable.
92
+ *
93
+ * A sibling of `inconclusive` and for the same reason: `pass` is false because nothing was
94
+ * proven, but nobody could have made it true, so grading it as a defect in the user's app is a
95
+ * false claim. Without this the verdict rule saw only `pass: false` and answered
96
+ * `assertion_failed` — "the declared consequence did not hold" — naming the component the agent
97
+ * had just clicked. See VerifiedReason.OBSERVATION_LOST.
98
+ */
99
+ observationLost?: boolean;
86
100
  /**
87
101
  * The failure, structured — what was seen, what was required, and which oracle judged it.
88
102
  *
@@ -9,115 +9,188 @@ import { describeObserved } from './observed-in-window.js';
9
9
  * the only key supplied left a predicate that asserts nothing and passes on anything.
10
10
  */
11
11
  const PREDICATE_ALIASES = {
12
- [PredicateKind.ROUTE]: { path: 'pathname' },
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' },
13
24
  [PredicateKind.NET]: { url: 'urlContains' },
14
25
  [PredicateKind.SIGNAL]: { data: 'dataMatches' },
15
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
+ ];
16
46
  /** Rename known aliases before parse; an explicit canonical key always wins. */
17
47
  function applyPredicateAliases(input) {
18
48
  if (typeof input !== 'object' || null === input || Array.isArray(input))
19
49
  return input;
20
50
  const obj = input;
21
- const aliases = PREDICATE_ALIASES['string' === typeof obj['kind'] ? obj['kind'] : ''];
22
- if (aliases === undefined)
23
- return input;
51
+ const kind = 'string' === typeof obj['kind'] ? obj['kind'] : '';
52
+ const aliases = PREDICATE_ALIASES[kind];
53
+ let out = obj;
54
+ if (aliases !== undefined) {
55
+ out = { ...obj };
56
+ for (const [from, to] of Object.entries(aliases)) {
57
+ if (out[from] === undefined)
58
+ continue;
59
+ if (out[to] === undefined)
60
+ out[to] = out[from];
61
+ delete out[from];
62
+ }
63
+ }
64
+ return PredicateKind.ELEMENT === kind ? liftElementQuery(out) : out;
65
+ }
66
+ /**
67
+ * Fold flat query fields into `query`. An explicit `query` wins outright — a caller that supplied
68
+ * both told us which one it meant, and merging the two would invent a locator neither side wrote.
69
+ */
70
+ function liftElementQuery(obj) {
71
+ const loose = ELEMENT_QUERY_FIELDS.filter((field) => obj[field] !== undefined);
72
+ if (0 === loose.length)
73
+ return obj;
24
74
  const out = { ...obj };
25
- for (const [from, to] of Object.entries(aliases)) {
26
- if (out[from] === undefined)
27
- continue;
28
- if (out[to] === undefined)
29
- out[to] = out[from];
30
- delete out[from];
75
+ const query = {};
76
+ for (const field of loose) {
77
+ query[field] = out[field];
78
+ delete out[field];
31
79
  }
80
+ if (out['query'] === undefined)
81
+ out['query'] = query;
32
82
  return out;
33
83
  }
34
84
  /**
35
85
  * Strict on every branch. A key that is nobody's spelling is now a schema error naming it, instead of
36
86
  * a stripped field and a green — see predicate-strict.test.ts for the MCP session that found this.
87
+ *
88
+ * Built on demand rather than at module scope: the `allOf`/`anyOf`/`not` branches reference
89
+ * `PredicateSchema` itself, which does not exist yet while this module is initialising. Calling it
90
+ * after init is what makes the union introspectable — see `predicateFieldsFor`.
91
+ */
92
+ function predicateUnion() {
93
+ return z.discriminatedUnion('kind', [
94
+ z
95
+ .object({
96
+ kind: z.literal(PredicateKind.ELEMENT),
97
+ query: ElementQuerySchema,
98
+ state: z.nativeEnum(ElementState).optional(),
99
+ absent: z.boolean().optional(),
100
+ })
101
+ .strict(),
102
+ z
103
+ .object({
104
+ kind: z.literal(PredicateKind.TEXT),
105
+ contains: z.string(),
106
+ visible: z.boolean().optional(),
107
+ absent: z.boolean().optional(),
108
+ })
109
+ .strict(),
110
+ z
111
+ .object({
112
+ kind: z.literal(PredicateKind.NET),
113
+ method: z.string().optional(),
114
+ urlContains: z.string().optional(),
115
+ status: z.number().optional(),
116
+ ok: z.boolean().optional(),
117
+ since: z.number().optional(),
118
+ count: z.number().int().nonnegative().optional(),
119
+ })
120
+ .strict(),
121
+ z
122
+ .object({
123
+ kind: z.literal(PredicateKind.ROUTE),
124
+ pathname: z.string().optional(),
125
+ contains: z.string().optional(),
126
+ since: z.number().optional(),
127
+ })
128
+ .strict(),
129
+ z
130
+ .object({
131
+ kind: z.literal(PredicateKind.CONSOLE),
132
+ level: z.string().optional(),
133
+ absent: z.boolean().optional(),
134
+ since: z.number().optional(),
135
+ })
136
+ .strict(),
137
+ z
138
+ .object({
139
+ kind: z.literal(PredicateKind.ANIMATION),
140
+ name: z.string().optional(),
141
+ target: z.string().optional(),
142
+ completed: z.boolean().optional(),
143
+ since: z.number().optional(),
144
+ })
145
+ .strict(),
146
+ z
147
+ .object({
148
+ kind: z.literal(PredicateKind.SIGNAL),
149
+ name: z.string().optional(),
150
+ dataMatches: z.record(z.unknown()).optional(),
151
+ since: z.number().optional(),
152
+ })
153
+ .strict(),
154
+ z
155
+ .object({
156
+ kind: z.literal(PredicateKind.STATE),
157
+ store: z.string().optional(),
158
+ path: z.string(),
159
+ equals: z.unknown().optional(),
160
+ })
161
+ .strict(),
162
+ z
163
+ .object({
164
+ kind: z.literal(PredicateKind.SETTLED),
165
+ quietMs: z.number().positive().optional(),
166
+ })
167
+ .strict(),
168
+ z
169
+ .object({ kind: z.literal(PredicateKind.ALL_OF), predicates: z.array(PredicateSchema) })
170
+ .strict(),
171
+ z
172
+ .object({ kind: z.literal(PredicateKind.ANY_OF), predicates: z.array(PredicateSchema) })
173
+ .strict(),
174
+ z.object({ kind: z.literal(PredicateKind.NOT), predicate: PredicateSchema }).strict(),
175
+ ]);
176
+ }
177
+ export const PredicateSchema = z.lazy(() => z.preprocess(applyPredicateAliases, predicateUnion()));
178
+ /**
179
+ * The fields a given predicate kind accepts, read off the schema itself.
180
+ *
181
+ * Derived rather than listed so the two can never disagree: a rejection message that names a stale
182
+ * field set is worse than one that names none, because the agent trusts it and retries into the same
183
+ * wall. Empty for a kind that is not in the union.
37
184
  */
38
- export const PredicateSchema = z.lazy(() => z.preprocess(applyPredicateAliases, z.discriminatedUnion('kind', [
39
- z
40
- .object({
41
- kind: z.literal(PredicateKind.ELEMENT),
42
- query: ElementQuerySchema,
43
- state: z.nativeEnum(ElementState).optional(),
44
- absent: z.boolean().optional(),
45
- })
46
- .strict(),
47
- z
48
- .object({
49
- kind: z.literal(PredicateKind.TEXT),
50
- contains: z.string(),
51
- visible: z.boolean().optional(),
52
- absent: z.boolean().optional(),
53
- })
54
- .strict(),
55
- z
56
- .object({
57
- kind: z.literal(PredicateKind.NET),
58
- method: z.string().optional(),
59
- urlContains: z.string().optional(),
60
- status: z.number().optional(),
61
- ok: z.boolean().optional(),
62
- since: z.number().optional(),
63
- count: z.number().int().nonnegative().optional(),
64
- })
65
- .strict(),
66
- z
67
- .object({
68
- kind: z.literal(PredicateKind.ROUTE),
69
- pathname: z.string().optional(),
70
- contains: z.string().optional(),
71
- since: z.number().optional(),
72
- })
73
- .strict(),
74
- z
75
- .object({
76
- kind: z.literal(PredicateKind.CONSOLE),
77
- level: z.string().optional(),
78
- absent: z.boolean().optional(),
79
- since: z.number().optional(),
80
- })
81
- .strict(),
82
- z
83
- .object({
84
- kind: z.literal(PredicateKind.ANIMATION),
85
- name: z.string().optional(),
86
- target: z.string().optional(),
87
- completed: z.boolean().optional(),
88
- since: z.number().optional(),
89
- })
90
- .strict(),
91
- z
92
- .object({
93
- kind: z.literal(PredicateKind.SIGNAL),
94
- name: z.string().optional(),
95
- dataMatches: z.record(z.unknown()).optional(),
96
- since: z.number().optional(),
97
- })
98
- .strict(),
99
- z
100
- .object({
101
- kind: z.literal(PredicateKind.STATE),
102
- store: z.string().optional(),
103
- path: z.string(),
104
- equals: z.unknown().optional(),
105
- })
106
- .strict(),
107
- z
108
- .object({
109
- kind: z.literal(PredicateKind.SETTLED),
110
- quietMs: z.number().positive().optional(),
111
- })
112
- .strict(),
113
- z
114
- .object({ kind: z.literal(PredicateKind.ALL_OF), predicates: z.array(PredicateSchema) })
115
- .strict(),
116
- z
117
- .object({ kind: z.literal(PredicateKind.ANY_OF), predicates: z.array(PredicateSchema) })
118
- .strict(),
119
- z.object({ kind: z.literal(PredicateKind.NOT), predicate: PredicateSchema }).strict(),
120
- ])));
185
+ export function predicateFieldsFor(kind) {
186
+ for (const option of predicateUnion().options) {
187
+ const literal = option.shape['kind'];
188
+ if (literal instanceof z.ZodLiteral && literal.value === kind) {
189
+ return Object.keys(option.shape).filter((field) => 'kind' !== field);
190
+ }
191
+ }
192
+ return [];
193
+ }
121
194
  function str(value) {
122
195
  return 'string' === typeof value ? value : undefined;
123
196
  }
@@ -208,6 +281,22 @@ function callSucceeded(data) {
208
281
  const status = num(data['status']);
209
282
  return status === undefined || status < 400;
210
283
  }
284
+ /**
285
+ * One call, as the failure report should name it: `POST /api/generate-script → 500`.
286
+ *
287
+ * The status used to be dropped here, which made the most common net failure unreadable. Asserting
288
+ * `{status: 200}` against a call that returned 500 reported only `POST /api/generate-script` — the
289
+ * very field the predicate filtered on was missing from the account of what was seen, so the agent
290
+ * is told the call happened and left to guess why it did not match.
291
+ *
292
+ * The arrow is OMITTED when there is no status. An in-flight or aborted request genuinely has none,
293
+ * and `→ undefined` would be a fabricated fact about the wire.
294
+ */
295
+ function describeCall(e) {
296
+ const head = `${str(e.data['method']) ?? 'GET'} ${str(e.data['url']) ?? ''}`;
297
+ const status = num(e.data['status']);
298
+ return status === undefined ? head : `${head} → ${String(status)}`;
299
+ }
211
300
  export function evalNet(events, p) {
212
301
  const since = p.since ?? 0;
213
302
  const matches = events.filter((e) => {
@@ -248,9 +337,7 @@ export function evalNet(events, p) {
248
337
  failureReason: `no network call matched ${JSON.stringify(p)}`,
249
338
  // Same reasoning as the signal miss: "no matching call" cannot be told apart from "the app
250
339
  // made no calls at all", and those need different fixes.
251
- observed: describeObserved('calls', events
252
- .filter((e) => e.type === EventType.NET_REQUEST)
253
- .map((e) => `${str(e.data['method']) ?? 'GET'} ${str(e.data['url']) ?? ''}`)),
340
+ observed: describeObserved('calls', events.filter((e) => e.type === EventType.NET_REQUEST).map(describeCall)),
254
341
  expected: `at least one call matching ${JSON.stringify(p)}`,
255
342
  assertion: 'net.present',
256
343
  };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * One readable sentence when a predicate does not parse — never the zod array.
3
+ *
4
+ * In the field a meaningful share of all tool errors were **a serialized zod issue array**, all on
5
+ * `reticle_act_and_wait`, `reticle_wait_for` and `reticle_assert` — the three tools that produced
6
+ * every action-derived finding in the dataset. The least readable error we emit was landing on the
7
+ * highest-value path, and an agent had to `JSON.parse` an error string to learn which field it got
8
+ * wrong.
9
+ *
10
+ * `PredicateSchema.parse()` throws a `ZodError` whose `.message` IS that array, and three handlers
11
+ * call it directly. This wraps it once, so every caller gets the same shape rather than each
12
+ * growing its own catch — the pattern #108 asks for: name the parameter, say whether anything ran,
13
+ * show a valid call.
14
+ */
15
+ import { z } from 'zod';
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
+ export declare function parsePredicate(input: unknown): z.infer<typeof PredicateSchema>;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * One readable sentence when a predicate does not parse — never the zod array.
3
+ *
4
+ * In the field a meaningful share of all tool errors were **a serialized zod issue array**, all on
5
+ * `reticle_act_and_wait`, `reticle_wait_for` and `reticle_assert` — the three tools that produced
6
+ * every action-derived finding in the dataset. The least readable error we emit was landing on the
7
+ * highest-value path, and an agent had to `JSON.parse` an error string to learn which field it got
8
+ * wrong.
9
+ *
10
+ * `PredicateSchema.parse()` throws a `ZodError` whose `.message` IS that array, and three handlers
11
+ * call it directly. This wraps it once, so every caller gets the same shape rather than each
12
+ * growing its own catch — the pattern #108 asks for: name the parameter, say whether anything ran,
13
+ * show a valid call.
14
+ */
15
+ import { z } from 'zod';
16
+ import { PredicateKind } from '@reticlehq/core';
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" }';
20
+ /** `path: ["net","urlContains"]` → `net.urlContains`; an empty path means the object itself. */
21
+ function pathOf(issue) {
22
+ return 0 === issue.path.length ? 'the predicate' : issue.path.map(String).join('.');
23
+ }
24
+ /**
25
+ * One issue, as a clause a human or an agent can act on.
26
+ *
27
+ * `unrecognized_keys` is the common case by far and the one worth spelling out — it is what a
28
+ * plausible-but-wrong field name produces, and the key itself is the whole answer.
29
+ */
30
+ function describeIssue(issue) {
31
+ if (z.ZodIssueCode.unrecognized_keys === issue.code) {
32
+ return `unknown field ${issue.keys.join(', ')}`;
33
+ }
34
+ return `${pathOf(issue)}: ${issue.message}`;
35
+ }
36
+ /**
37
+ * Parse a predicate, or throw an Error whose message is a sentence.
38
+ *
39
+ * Deliberately still THROWS: every call site already handles a throw, and turning this into a
40
+ * result type would mean touching three handlers to gain nothing the message does not already say.
41
+ */
42
+ export function parsePredicate(input) {
43
+ const parsed = PredicateSchema.safeParse(input);
44
+ if (parsed.success)
45
+ return parsed.data;
46
+ const kind = 'object' === typeof input && null !== input && 'kind' in input
47
+ ? String(input['kind'])
48
+ : 'unknown';
49
+ // Bounded on purpose: a union rejection can produce one issue per member, and pasting all of them
50
+ // back is how the zod array became unreadable in the first place.
51
+ const issues = parsed.error.issues.slice(0, 3).map(describeIssue).join('; ');
52
+ throw new Error(`that predicate did not parse (kind "${kind}"): ${issues}. Nothing ran — the predicate was ` +
53
+ `not evaluated, so no verdict was produced. ${accepted(kind)} ` +
54
+ `A valid call looks like: ${EXAMPLE}`);
55
+ }
56
+ /**
57
+ * What WOULD have worked, in the same breath as what did not.
58
+ *
59
+ * Telling an agent only which field is wrong leaves it guessing again, and each guess costs another
60
+ * round trip on the one call path that produces verdicts. Naming the accepted fields — or, when the
61
+ * kind itself is the mistake, the accepted kinds — makes the retry informed instead.
62
+ */
63
+ function accepted(kind) {
64
+ const fields = predicateFieldsFor(kind);
65
+ if (0 < fields.length)
66
+ return `${kind} accepts: ${fields.join(', ')}.`;
67
+ return `"${kind}" is not a predicate kind — use one of: ${Object.values(PredicateKind).join(', ')}.`;
68
+ }
@@ -17,6 +17,11 @@ export interface PredicateSession {
17
17
  * Optional: a session without ambient learning simply omits it and settle behaves as before.
18
18
  */
19
19
  ambientCounts?(): AmbientCounts;
20
+ /**
21
+ * Subscribe to session disconnect. Returns an unsubscribe function. Optional: a session without
22
+ * this hook (e.g. tests that never disconnect) simply leaves in-flight predicates until timeout.
23
+ */
24
+ onDisconnect?(listener: () => void): () => void;
20
25
  }
21
26
  export declare function evaluatePredicate(session: PredicateSession, predicate: Predicate, since?: number, diagnose?: boolean): Promise<EvalResult>;
22
27
  /**
@@ -305,6 +305,7 @@ export function waitForPredicate(session, predicate, timeoutMs, since = 0) {
305
305
  return;
306
306
  done = true;
307
307
  unsub();
308
+ unsubDisconnect?.();
308
309
  clearInterval(interval);
309
310
  clearTimeout(timer);
310
311
  if (cooldownTimer !== undefined)
@@ -398,6 +399,13 @@ export function waitForPredicate(session, predicate, timeoutMs, since = 0) {
398
399
  * rejection reached it as an unhandledRejection. Either way the wait never resolved and the tool
399
400
  * handler never returned — the agent sees a call that simply never comes back.
400
401
  *
402
+ * This half covers the SYNCHRONOUS throw only. The rejection half is handled inside `check`, by
403
+ * the `.catch` on its own promise chain — `check` returns void and voids the promise it starts,
404
+ * so there is nothing to hand back here to await. Said explicitly because the obvious-looking
405
+ * `if (result instanceof Promise) result.catch(…)` that used to sit here was dead code that read
406
+ * like the rejection guard, and removing `check`'s own `.catch` as redundant would have restored
407
+ * the exact hang this exists to prevent.
408
+ *
401
409
  * That is the exact shape reported from a Plane (Next 14 + MobX) session, which tears the page
402
410
  * session down and rebuilds it on EVERY navigation, so a `{kind:"route"}` predicate always races
403
411
  * a teardown of the very session it is watching: `browser.command ok:true` for the click, and
@@ -407,9 +415,7 @@ export function waitForPredicate(session, predicate, timeoutMs, since = 0) {
407
415
  */
408
416
  const guardedCheck = () => {
409
417
  try {
410
- const pending = check();
411
- if (pending instanceof Promise)
412
- pending.catch(failWait);
418
+ check();
413
419
  }
414
420
  catch (error) {
415
421
  failWait(error);
@@ -424,6 +430,14 @@ export function waitForPredicate(session, predicate, timeoutMs, since = 0) {
424
430
  const unsub = session.onEvent(() => {
425
431
  boundCheck();
426
432
  });
433
+ const unsubDisconnect = session.onDisconnect?.(() => {
434
+ // `observationLost` is what stops this being graded as an app defect. `pass: false` is still
435
+ // correct — the consequence was not seen to hold — but on its own it reached the verdict rule
436
+ // as ASSERTION_FAILED, so a reload mid-wait reported "the declared consequence did not hold"
437
+ // against a healthy component, by file and line. The flag is structured rather than inferred
438
+ // from this string, because every other `failureReason` here is prose about the APP.
439
+ finish({ pass: false, failureReason: 'session disconnected', observationLost: true });
440
+ });
427
441
  const interval = setInterval(boundCheck, POLL_INTERVAL_MS);
428
442
  const timer = setTimeout(() => {
429
443
  void evaluatePredicate(session, predicate, since)
@@ -24,6 +24,14 @@ export declare class RingBuffer {
24
24
  since(cursor: number): ReticleEvent[];
25
25
  /** Events within the last `windowMs`, relative to `now`. */
26
26
  window(windowMs: number, now: number): ReticleEvent[];
27
+ /**
28
+ * Did this buffer evict scarce evidence that belonged to a window opened at `cursor`?
29
+ *
30
+ * The one honest input to "was the capture clean". A `false` here means every non-churn event in
31
+ * the window is still held, whatever the drop counter says — and the drop counter says a great
32
+ * deal, because age eviction runs on every push.
33
+ */
34
+ lostSince(cursor: number): boolean;
27
35
  /** Snapshot of buffer health for the agent — live events held and cumulative drops since connect. */
28
36
  bufferHealth(): {
29
37
  total: number;