@reticlehq/server 2.5.0 → 2.6.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 (147) hide show
  1. package/SKILL.md +27 -10
  2. package/dist/bridge/bridge.d.ts +12 -0
  3. package/dist/bridge/bridge.js +32 -2
  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/cli-doctor.d.ts +0 -5
  7. package/dist/cli/cli-doctor.js +71 -3
  8. package/dist/cli/cli-parse-feedback.d.ts +31 -0
  9. package/dist/cli/cli-parse-feedback.js +56 -0
  10. package/dist/cli/cli-parse.d.ts +5 -9
  11. package/dist/cli/cli-parse.js +7 -23
  12. package/dist/cli/doctor-daemon-line.d.ts +37 -0
  13. package/dist/cli/doctor-daemon-line.js +35 -0
  14. package/dist/cli/port-holder.d.ts +43 -0
  15. package/dist/cli/port-holder.js +99 -0
  16. package/dist/cli.js +131 -11
  17. package/dist/daemon/daemon-resilience.d.ts +24 -0
  18. package/dist/daemon/daemon-resilience.js +105 -17
  19. package/dist/daemon/daemon.js +26 -1
  20. package/dist/daemon/heartbeat.d.ts +115 -0
  21. package/dist/daemon/heartbeat.js +141 -0
  22. package/dist/daemon/idle-shutdown.d.ts +13 -5
  23. package/dist/daemon/idle-shutdown.js +14 -6
  24. package/dist/daemon/port-presence.d.ts +76 -0
  25. package/dist/daemon/port-presence.js +93 -0
  26. package/dist/daemon/state-dir.d.ts +7 -0
  27. package/dist/daemon/state-dir.js +59 -0
  28. package/dist/daemon/unclean-predecessor.d.ts +23 -0
  29. package/dist/daemon/unclean-predecessor.js +27 -0
  30. package/dist/events/predicate-eval.d.ts +10 -0
  31. package/dist/events/predicate-eval.js +24 -4
  32. package/dist/events/predicate-parse.d.ts +23 -0
  33. package/dist/events/predicate-parse.js +54 -0
  34. package/dist/events/predicate.d.ts +5 -0
  35. package/dist/events/predicate.js +17 -3
  36. package/dist/events/ring-buffer.js +1 -16
  37. package/dist/flows/flow-replay.d.ts +1 -1
  38. package/dist/flows/flow-replay.js +19 -2
  39. package/dist/flows/flow-step-runners.js +2 -3
  40. package/dist/flows/flow-tools.js +10 -14
  41. package/dist/flows/role-drift-reason.d.ts +20 -0
  42. package/dist/flows/role-drift-reason.js +39 -0
  43. package/dist/flows/suite-flakes.d.ts +33 -0
  44. package/dist/flows/suite-flakes.js +32 -0
  45. package/dist/honesty/blind-spots.d.ts +19 -0
  46. package/dist/honesty/blind-spots.js +33 -0
  47. package/dist/honesty/verified.d.ts +10 -1
  48. package/dist/honesty/verified.js +16 -0
  49. package/dist/http-server.js +9 -0
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.js +10 -1
  52. package/dist/init/agent-rules.d.ts +18 -0
  53. package/dist/init/agent-rules.js +22 -4
  54. package/dist/init/cra.d.ts +13 -0
  55. package/dist/init/cra.js +16 -0
  56. package/dist/init/detect.d.ts +6 -0
  57. package/dist/init/detect.js +8 -0
  58. package/dist/init/mcp-clients.d.ts +81 -0
  59. package/dist/init/mcp-clients.js +248 -0
  60. package/dist/init/node-io.js +8 -2
  61. package/dist/init/plan-framework.js +36 -2
  62. package/dist/init/plan.d.ts +14 -0
  63. package/dist/init/plan.js +73 -9
  64. package/dist/init/run.d.ts +18 -1
  65. package/dist/init/run.js +133 -16
  66. package/dist/init/windows-quote.d.ts +38 -0
  67. package/dist/init/windows-quote.js +70 -0
  68. package/dist/mcp/mcp-outage.d.ts +1 -0
  69. package/dist/mcp/mcp-outage.js +8 -1
  70. package/dist/mcp/mcp-proxy.d.ts +11 -1
  71. package/dist/mcp/mcp-proxy.js +30 -7
  72. package/dist/mcp/mcp.js +29 -2
  73. package/dist/pool/browser-pool.d.ts +9 -0
  74. package/dist/pool/browser-pool.js +38 -9
  75. package/dist/session/attachment-history.d.ts +46 -0
  76. package/dist/session/attachment-history.js +65 -0
  77. package/dist/session/no-session-diagnosis.d.ts +7 -0
  78. package/dist/session/no-session-diagnosis.js +53 -3
  79. package/dist/session/no-session-watch.d.ts +3 -1
  80. package/dist/session/no-session-watch.js +10 -2
  81. package/dist/session/pending-commands.js +1 -0
  82. package/dist/session/session-info.d.ts +17 -1
  83. package/dist/session/session-info.js +5 -1
  84. package/dist/session/session-manager.d.ts +3 -0
  85. package/dist/session/session-manager.js +72 -3
  86. package/dist/session/session.d.ts +1 -0
  87. package/dist/session/session.js +8 -0
  88. package/dist/telemetry/crash-cause.d.ts +47 -0
  89. package/dist/telemetry/crash-cause.js +104 -0
  90. package/dist/telemetry/daemon-telemetry.d.ts +25 -7
  91. package/dist/telemetry/daemon-telemetry.js +28 -13
  92. package/dist/telemetry/error-class.d.ts +26 -0
  93. package/dist/telemetry/error-class.js +52 -0
  94. package/dist/telemetry/feedback-cli.d.ts +7 -2
  95. package/dist/telemetry/feedback-cli.js +49 -5
  96. package/dist/telemetry/feedback-context.d.ts +1 -6
  97. package/dist/telemetry/feedback-context.js +64 -4
  98. package/dist/telemetry/git-facts.d.ts +7 -0
  99. package/dist/telemetry/git-facts.js +4 -4
  100. package/dist/telemetry/project-profile.js +2 -1
  101. package/dist/telemetry/session-metrics.d.ts +45 -3
  102. package/dist/telemetry/session-metrics.js +247 -9
  103. package/dist/telemetry/telemetry.d.ts +19 -1
  104. package/dist/telemetry/telemetry.js +31 -8
  105. package/dist/tools/act-tools.js +33 -6
  106. package/dist/tools/assert-verdict.d.ts +7 -1
  107. package/dist/tools/assert-verdict.js +15 -3
  108. package/dist/tools/browser-tools.js +8 -2
  109. package/dist/tools/contract-tools.d.ts +0 -5
  110. package/dist/tools/contract-tools.js +26 -0
  111. package/dist/tools/dynamic-tools.js +6 -1
  112. package/dist/tools/error-recovery.d.ts +14 -0
  113. package/dist/tools/error-recovery.js +83 -7
  114. package/dist/tools/feedback-invite.d.ts +48 -0
  115. package/dist/tools/feedback-invite.js +71 -0
  116. package/dist/tools/invoke-tool.d.ts +0 -6
  117. package/dist/tools/invoke-tool.js +69 -1
  118. package/dist/tools/navigate-arrival.d.ts +26 -0
  119. package/dist/tools/navigate-arrival.js +60 -0
  120. package/dist/tools/navigate-result.d.ts +15 -1
  121. package/dist/tools/navigate-result.js +17 -10
  122. package/dist/tools/observe-tools.js +22 -4
  123. package/dist/tools/real-input-attempt.js +10 -5
  124. package/dist/tools/tool-kit.d.ts +8 -0
  125. package/dist/tools/tool-surface.js +13 -0
  126. package/docs/README.md +55 -0
  127. package/docs/agent-cheatsheet.md +2 -2
  128. package/docs/benchmarks.md +1 -1
  129. package/docs/fixtures-dispatch-receiver.yml +76 -0
  130. package/docs/fixtures.md +17 -0
  131. package/docs/gate-plan.md +98 -0
  132. package/docs/gates.md +117 -0
  133. package/docs/local-registry.md +1 -1
  134. package/docs/matrix/2.5.0/claude-code-machine.json +19 -0
  135. package/docs/matrix/2.5.0/codex-machine.json +20 -0
  136. package/docs/matrix/2.5.0/cursor-machine.json +22 -0
  137. package/docs/matrix/2.5.0/gemini-machine.json +22 -0
  138. package/docs/matrix/2.5.0/opencode-machine.json +22 -0
  139. package/docs/matrix/2.5.0/vscode-machine.json +22 -0
  140. package/docs/matrix/2.5.0/windsurf-machine.json +22 -0
  141. package/docs/matrix/MATRIX.md +26 -0
  142. package/docs/matrix/README.md +59 -0
  143. package/docs/mint.json +1 -1
  144. package/docs/system-map.md +152 -0
  145. package/docs/telemetry-contract.md +2 -2
  146. package/docs/telemetry.md +7 -0
  147. package/package.json +2 -2
@@ -1,5 +1,5 @@
1
1
  import { healthEnvelope } from '../session/session-health.js';
2
- import { TelemetryActor, TelemetryEventKind } from '@reticlehq/core';
2
+ import { TelemetryActor, TelemetryEventKind, TRANSPORT_LIMITS, } from '@reticlehq/core';
3
3
  import { getSessionMetrics } from '../telemetry/session-metrics.js';
4
4
  import { getTelemetry } from '../telemetry/telemetry.js';
5
5
  import { takeUpdateNudge } from '../update/update-nudge.js';
@@ -12,6 +12,7 @@ import { ReticleTool } from './tool-names.js';
12
12
  import { takeFeedbackPrompt } from './feedback-tools.js';
13
13
  import { takeFeedbackUndelivered } from '../telemetry/feedback-delivery.js';
14
14
  import { span } from '../trace.js';
15
+ import { frictionOf, inviteFor } from './feedback-invite.js';
15
16
  /**
16
17
  * The live-session tools whose result MUST carry the
17
18
  * session-health envelope. Owned in ONE place — not retrofitted per handler — so a throttled tab
@@ -138,6 +139,27 @@ function reportBugsFound(toolName, result) {
138
139
  * object that did not already include `session` — splices the health envelope on. Idempotent
139
140
  * (handlers that already add health are left untouched) and never alters non-object results.
140
141
  */
142
+ /**
143
+ * The name of the first argument whose string value exceeds the transport bound, if any.
144
+ *
145
+ * One level of nesting is searched because that is where callers put payloads — `reticle_act`'s
146
+ * `args.value`, and the fuzz's own `huge-string` shape. Deeper recursion would cost more than it
147
+ * buys: the failure is a single huge string, not a deeply nested structure of small ones.
148
+ */
149
+ function firstOversizedArg(args) {
150
+ const tooLong = (value) => 'string' === typeof value && value.length > TRANSPORT_LIMITS.MAX_STRING_LENGTH;
151
+ for (const [key, value] of Object.entries(args)) {
152
+ if (tooLong(value))
153
+ return key;
154
+ if ('object' === typeof value && null !== value && !Array.isArray(value)) {
155
+ for (const [inner, nested] of Object.entries(value)) {
156
+ if (tooLong(nested))
157
+ return `${key}.${inner}`;
158
+ }
159
+ }
160
+ }
161
+ return undefined;
162
+ }
141
163
  export async function runTool(tool, deps, args) {
142
164
  // Both dispatch paths (MCP + programmatic) pass through here — the one place "which tool is mostly
143
165
  // used" can be counted. This used to EMIT an event per call; it now increments an in-process counter
@@ -150,6 +172,28 @@ export async function runTool(tool, deps, args) {
150
172
  // closure carries this call's own identity, which is also what makes peak-concurrency measurable.
151
173
  // A daemon that has served even one tool call is doing a job for somebody; see daemon-usefulness.
152
174
  noteToolCall();
175
+ // An oversized argument is refused BEFORE the handler deserialises it.
176
+ //
177
+ // tool-fuzz failed CI on the invariant that matters most — `every tool answers every hostile call`
178
+ // — for a ~100KB string. Seen twice with different tools; the earlier one came back
179
+ // `-32001 sse_aborted` with the tool surface dropping 48→17, which is a RESTARTED daemon. The
180
+ // shape is: oversized argument → the daemon dies → the proxy answers the in-flight call with
181
+ // transport loss, and the next assertion is talking to a different daemon.
182
+ //
183
+ // `TRANSPORT_LIMITS.MAX_STRING_LENGTH` already bounds what crosses the bridge; a tool argument
184
+ // arrives over MCP stdio and never met it. Here, because this is the one place every invocation
185
+ // routes through, so a tool added later inherits the bound instead of having to remember it.
186
+ //
187
+ // A refusal is a normal answer. An UNANSWERED call is a hung agent, and that is the failure this
188
+ // prevents: the agent learns its argument was too big, which is something it can fix.
189
+ const oversized = firstOversizedArg(args);
190
+ if (oversized !== undefined) {
191
+ return {
192
+ error: `argument '${oversized}' is larger than ${String(TRANSPORT_LIMITS.MAX_STRING_LENGTH)} ` +
193
+ 'characters, which is the most Reticle moves in one call. Nothing ran. Send a smaller ' +
194
+ 'value — a selector or an id rather than a document, or a slice of the text you need.',
195
+ };
196
+ }
153
197
  // An ACTION is what gets abandoned. Counted here, against verifications, so "the agent drove the
154
198
  // page and then wandered off" becomes a number instead of an impression.
155
199
  if (ACTION_TOOLS.has(tool.name))
@@ -246,5 +290,29 @@ export async function runTool(tool, deps, args) {
246
290
  const warning = resolved.ageWarning();
247
291
  if (warning !== undefined)
248
292
  envelope['session_age_warning'] = warning;
293
+ // Ask for a verdict when the agent has driven the page and not asked for one. 137 of the 140
294
+ // verdict-less sessions measured over 2026-08-10/11 never called a verdict-producing tool ONCE;
295
+ // the counter behind this already existed and was reported only to us. One-shot per abandoned
296
+ // run, same discipline as the pool lease — a hint on every call is noise that gets tuned out.
297
+ const unverified = getSessionMetrics().takeUnverifiedNudge();
298
+ if (unverified !== undefined)
299
+ envelope['verify_next'] = unverified;
300
+ // Invite feedback at the moment of friction, worded for what just happened. Feedback produced
301
+ // ~11 of this release's fixes from 14 reports; the handshake instruction is what got those 14,
302
+ // and this closes the gap it leaves — the agent mid-problem, not thinking about feedback tooling.
303
+ // Counted so `feedback_submitted / feedbackPrompted` can tell us whether it works at all.
304
+ const friction = frictionOf({
305
+ unknownTool: false,
306
+ verifiedUnknown: 'unknown' === result.verified,
307
+ repeatRun: getSessionMetrics().currentRun,
308
+ errored: 'error' in result,
309
+ // A verdict is progress. Without this, three successful act_and_wait calls in a row — the exact
310
+ // loop this release exists to encourage — get told they are stuck.
311
+ producedVerdict: 'verified' in result,
312
+ });
313
+ if (friction !== undefined) {
314
+ envelope['feedback_invite'] = inviteFor(friction);
315
+ getSessionMetrics().recordFeedbackPrompt();
316
+ }
249
317
  return Object.keys(envelope).length > 0 ? { ...result, ...envelope } : result;
250
318
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Wait, briefly, for the SDK to reconnect after a navigation — so `confirmed` can mean something.
3
+ *
4
+ * `window.location.assign(url)` destroys the SDK that would report on the new document, so the
5
+ * navigation itself cannot be confirmed from inside the page. But the SDK reconnects TO THE DAEMON,
6
+ * which makes the daemon the only party that can see arrival happen. Before this, it did not look,
7
+ * and told the agent to poll `reticle_sessions` instead — a tool call on every navigation, on the
8
+ * least reliable tool we ship.
9
+ *
10
+ * Bounded and best-effort by construction: a navigation to a page that is not instrumented, or not
11
+ * there at all, must still return promptly with `confirmed:false` rather than hanging. The clock and
12
+ * the sleep are injected so this is testable without waiting on a real one.
13
+ */
14
+ import type { SessionManager } from '../session/session-manager.js';
15
+ import type { NavigateArrival } from './navigate-result.js';
16
+ /** Long enough for a dev server to serve a page and the SDK to dial back; short enough to not hang. */
17
+ export declare const ARRIVAL_TIMEOUT_MS = 5000;
18
+ export interface ArrivalClock {
19
+ now: () => number;
20
+ sleep: (ms: number) => Promise<void>;
21
+ }
22
+ /**
23
+ * Poll until a session is present at `target`, or the budget runs out. Returns `null` on timeout —
24
+ * never throws, because a failure to confirm is a legitimate answer, not an error.
25
+ */
26
+ export declare function awaitArrival(sessions: SessionManager, target: string, timeoutMs?: number, clock?: ArrivalClock): Promise<NavigateArrival | null>;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Wait, briefly, for the SDK to reconnect after a navigation — so `confirmed` can mean something.
3
+ *
4
+ * `window.location.assign(url)` destroys the SDK that would report on the new document, so the
5
+ * navigation itself cannot be confirmed from inside the page. But the SDK reconnects TO THE DAEMON,
6
+ * which makes the daemon the only party that can see arrival happen. Before this, it did not look,
7
+ * and told the agent to poll `reticle_sessions` instead — a tool call on every navigation, on the
8
+ * least reliable tool we ship.
9
+ *
10
+ * Bounded and best-effort by construction: a navigation to a page that is not instrumented, or not
11
+ * there at all, must still return promptly with `confirmed:false` rather than hanging. The clock and
12
+ * the sleep are injected so this is testable without waiting on a real one.
13
+ */
14
+ /** Long enough for a dev server to serve a page and the SDK to dial back; short enough to not hang. */
15
+ export const ARRIVAL_TIMEOUT_MS = 5_000;
16
+ const POLL_MS = 100;
17
+ /**
18
+ * Compare only origin + pathname.
19
+ *
20
+ * The app is entitled to add or rewrite the query and hash on arrival — a redirect to
21
+ * `?redirect=%2F`, a router normalising a trailing slash, an auth guard appending a reason. Matching
22
+ * the whole URL string would report `confirmed:false` for a navigation that plainly succeeded, which
23
+ * is the same lie in the opposite direction.
24
+ */
25
+ function samePage(a, b) {
26
+ try {
27
+ const x = new URL(a);
28
+ const y = new URL(b);
29
+ return x.origin === y.origin && x.pathname.replace(/\/$/, '') === y.pathname.replace(/\/$/, '');
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ function findArrival(sessions, target) {
36
+ for (const s of sessions.all()) {
37
+ if (samePage(s.url, target))
38
+ return { sessionId: s.id };
39
+ }
40
+ return null;
41
+ }
42
+ const REAL_CLOCK = {
43
+ now: () => Date.now(),
44
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
45
+ };
46
+ /**
47
+ * Poll until a session is present at `target`, or the budget runs out. Returns `null` on timeout —
48
+ * never throws, because a failure to confirm is a legitimate answer, not an error.
49
+ */
50
+ export async function awaitArrival(sessions, target, timeoutMs = ARRIVAL_TIMEOUT_MS, clock = REAL_CLOCK) {
51
+ const deadline = clock.now() + timeoutMs;
52
+ for (;;) {
53
+ const found = findArrival(sessions, target);
54
+ if (found !== null)
55
+ return found;
56
+ if (clock.now() >= deadline)
57
+ return null;
58
+ await clock.sleep(POLL_MS);
59
+ }
60
+ }
@@ -10,8 +10,22 @@
10
10
  * `reticle_act` already draws this line — `dispatched` (sent) versus `settled` (a frame flushed).
11
11
  * This gives navigate the same honesty without changing what it does.
12
12
  */
13
+ /** The session the SDK reconnected as after the page moved, when one was observed in the window. */
14
+ export interface NavigateArrival {
15
+ sessionId: string;
16
+ }
13
17
  export declare function navigateResult(result: {
14
18
  ok?: unknown;
15
19
  url?: unknown;
16
20
  reason?: unknown;
17
- }): Record<string, unknown>;
21
+ },
22
+ /**
23
+ * Arrival, if the daemon saw the SDK come back. `null` keeps the original honest envelope.
24
+ *
25
+ * Measured on three fixtures: `confirmed` was `false` on every navigation on every app, because
26
+ * nothing ever set it. A boolean that never varies teaches an agent nothing, and an agent that
27
+ * learns to ignore it will keep ignoring it when it starts meaning something. The daemon is the
28
+ * one place that CAN answer this — the SDK reconnects to it — so making it answer removes a
29
+ * `reticle_sessions` poll from every single navigation.
30
+ */
31
+ arrival?: NavigateArrival | null): Record<string, unknown>;
@@ -1,16 +1,14 @@
1
+ export function navigateResult(result,
1
2
  /**
2
- * The honest envelope for a navigation nobody can confirm.
3
+ * Arrival, if the daemon saw the SDK come back. `null` keeps the original honest envelope.
3
4
  *
4
- * `window.location.assign(url)` returns before the page moves, and the SDK that would report on the
5
- * new document is destroyed by the navigation itself. So a successful NAVIGATE means the browser
6
- * accepted the instruction — not that anything arrived. Driven against a dead URL it returned
7
- * `{"ok":true}` while the session died, which is the worst combination available: the agent believes
8
- * it navigated, is looking at nothing, and has lost Reticle.
9
- *
10
- * `reticle_act` already draws this line — `dispatched` (sent) versus `settled` (a frame flushed).
11
- * This gives navigate the same honesty without changing what it does.
5
+ * Measured on three fixtures: `confirmed` was `false` on every navigation on every app, because
6
+ * nothing ever set it. A boolean that never varies teaches an agent nothing, and an agent that
7
+ * learns to ignore it will keep ignoring it when it starts meaning something. The daemon is the
8
+ * one place that CAN answer this — the SDK reconnects to it — so making it answer removes a
9
+ * `reticle_sessions` poll from every single navigation.
12
10
  */
13
- export function navigateResult(result) {
11
+ arrival = null) {
14
12
  const ok = true === result.ok;
15
13
  const base = {
16
14
  ok,
@@ -20,6 +18,15 @@ export function navigateResult(result) {
20
18
  // A refusal is conclusive: the page never moved, so there is nothing unconfirmed to report.
21
19
  if (!ok)
22
20
  return base;
21
+ if (arrival !== null) {
22
+ return {
23
+ ...base,
24
+ confirmed: true,
25
+ // The SDK that reconnects after a navigation is a NEW session with a new id. Returning it here
26
+ // is the difference between a confirmation and a confirmation the agent can act on.
27
+ sessionId: arrival.sessionId,
28
+ };
29
+ }
23
30
  return {
24
31
  ...base,
25
32
  confirmed: false,
@@ -10,6 +10,7 @@ import { ReticleTool } from './tool-names.js';
10
10
  import { buildReactionReport } from '../events/reaction.js';
11
11
  import { findContradictions } from '../events/contradictions.js';
12
12
  import { evaluatePredicate, waitForPredicate, PredicateSchema } from '../events/predicate.js';
13
+ import { parsePredicate } from '../events/predicate-parse.js';
13
14
  import { matchNet, matchConsole, isConsoleEvent, eventMatchesFilters, netEmptyHint, consoleEmptyHint, reconcileNet, projectNetCall, projectConsoleLog, } from '../events/event-filters.js';
14
15
  import { applyEventBudget, costHint, withSizeCost, DEFAULT_QUERY_LIMIT, } from '../session/output-budget.js';
15
16
  import { healthEnvelope, bufferEnvelope } from '../session/session-health.js';
@@ -209,6 +210,10 @@ export const OBSERVE_TOOLS = [
209
210
  pass: z.boolean(),
210
211
  evidence: z.unknown().optional(),
211
212
  failureReason: z.string().optional(),
213
+ observationLost: z
214
+ .boolean()
215
+ .optional()
216
+ .describe('The tab disconnected mid-wait, so this was never observed — the verdict is UNKNOWN, not a failure of the app.'),
212
217
  session: z
213
218
  .object({ lastSeenMs: z.number(), throttled: z.boolean(), focused: z.boolean() })
214
219
  .optional(),
@@ -233,7 +238,7 @@ export const OBSERVE_TOOLS = [
233
238
  handler: async (deps, args) => {
234
239
  const session = deps.sessions.resolve(asString(args['sessionId']));
235
240
  // `until` is act_and_wait's name for this — see alias-args.ts.
236
- const predicate = PredicateSchema.parse(aliasParam(args, 'predicate', ['until'])['predicate']);
241
+ const predicate = parsePredicate(aliasParam(args, 'predicate', ['until'])['predicate']);
237
242
  // Honesty: explicit since wins; else default to the last act's cursor; else the whole buffer.
238
243
  const since = asNumber(args['since']) ?? session.lastAct.cursor() ?? 0;
239
244
  const verdict = await waitForPredicate(session, predicate, asNumber(args['timeout_ms']) ?? DEFAULT_ASSERT_TIMEOUT_MS, since);
@@ -252,7 +257,16 @@ export const OBSERVE_TOOLS = [
252
257
  example: { predicate: { kind: PredicateKind.SIGNAL, name: 'todos:loaded' } },
253
258
  description: 'Evaluate a predicate (optionally waiting up to timeout_ms). Returns { pass, evidence, failureReason? }. The end of every verify loop. Prefer a { signal } or { net } consequence over { element }/{ text } presence — a passing presence-only assertion returns `advice` because a wrong/healed element can fake it. By default it only counts events since your last act, so a stale buffered signal can never fake a pass; pass `since` (an observe/act cursor) to set the window explicitly.',
254
259
  inputSchema: {
255
- predicate: PredicateSchema.optional().describe('Predicate to evaluate: { signal }, { net }, { element } or a combination.'),
260
+ predicate: PredicateSchema.optional().describe(
261
+ // Every kind, each with the field that carries its argument. Five of the nine were
262
+ // undocumented here, including `route` — and "did submitting the login form navigate away"
263
+ // is the most common thing an agent wants to assert. A field report reached us from an agent
264
+ // that guessed `urlContains` on route (net's spelling) and got unrecognized_keys.
265
+ 'Predicate to evaluate. Kinds: { signal, name } { net, urlContains|method|status|count } ' +
266
+ '{ state, path|equals } { route, pathname (exact) | contains (path+query+hash) } ' +
267
+ '{ element, testid|role|text } { text } { console, level|absent } { animation, name } ' +
268
+ '{ settled } — combine with { allOf | anyOf | not }. Prefer a signal/net/state consequence ' +
269
+ 'over element/text presence.'),
256
270
  until: PredicateSchema.optional().describe("Alias for `predicate` (act_and_wait's name)."),
257
271
  timeout_ms: z
258
272
  .number()
@@ -268,6 +282,10 @@ export const OBSERVE_TOOLS = [
268
282
  pass: z.boolean(),
269
283
  evidence: z.unknown().optional(),
270
284
  failureReason: z.string().optional(),
285
+ observationLost: z
286
+ .boolean()
287
+ .optional()
288
+ .describe('The tab disconnected mid-wait, so this was never observed — the verdict is UNKNOWN, not a failure of the app.'),
271
289
  advice: z
272
290
  .string()
273
291
  .optional()
@@ -304,7 +322,7 @@ export const OBSERVE_TOOLS = [
304
322
  handler: async (deps, args) => {
305
323
  const session = deps.sessions.resolve(asString(args['sessionId']));
306
324
  // `until` is act_and_wait's name for this — see alias-args.ts.
307
- const predicate = PredicateSchema.parse(aliasParam(args, 'predicate', ['until'])['predicate']);
325
+ const predicate = parsePredicate(aliasParam(args, 'predicate', ['until'])['predicate']);
308
326
  const timeout = asNumber(args['timeout_ms']) ?? 0;
309
327
  // Honesty: explicit since wins; else default to the last act's cursor; else the whole buffer.
310
328
  const since = asNumber(args['since']) ?? session.lastAct.cursor() ?? 0;
@@ -323,7 +341,7 @@ export const OBSERVE_TOOLS = [
323
341
  : assertsDerivedIpcStatus(predicate)
324
342
  ? { advice: DERIVED_IPC_STATUS_ADVICE }
325
343
  : {};
326
- const { decision, contradictions, coverage } = await assertVerdict(session, predicate, verdict.pass, since, verdict.inconclusive);
344
+ const { decision, contradictions, coverage } = await assertVerdict(session, predicate, verdict.pass, since, verdict.inconclusive, verdict.observationLost);
327
345
  return withControl(session, {
328
346
  ...decision,
329
347
  ...verdict,
@@ -27,17 +27,22 @@ function synthetic(reason) {
27
27
  */
28
28
  export async function tryRealInput(deps, session, ref, action, args) {
29
29
  const provider = deps.realInput;
30
- if (provider === undefined)
31
- return synthetic(); // real input not configured — no diagnostic
30
+ const inner = asRecord(args['args']);
31
+ const askedForNative = true === inner[NATIVE_INPUT_ARG];
32
+ if (provider === undefined) {
33
+ // Silent by default: with no provider EVERY action is synthetic, and a reason on all of them is
34
+ // noise on the most-used tool in the product. But an agent that passed native:true asked a
35
+ // question and got the opposite answer — reported from the field as a silent downgrade that cost
36
+ // real debugging time, because the tool description promises a reason is "never silent".
37
+ return askedForNative ? synthetic(InputModeReason.NOT_CONFIGURED) : synthetic();
38
+ }
32
39
  if (!isPointerAction(action))
33
40
  return synthetic(InputModeReason.NOT_POINTER); // fill/type stay synthetic
34
- const inner = asRecord(args['args']);
35
41
  // "Don't click, run the code": a click/dblclick runs the occlusion-honest SYNTHETIC path by default
36
42
  // even with a provider configured — no coordinate gesture to be intercepted by the HUD or missed
37
43
  // off-screen. Opt into a trusted native click with args.native:true (file pickers, clipboard,
38
44
  // isTrusted-gated handlers). hover/drag genuinely need native pointer state, so they stay real.
39
- if ((action === ActionType.CLICK || action === ActionType.DBLCLICK) &&
40
- inner[NATIVE_INPUT_ARG] !== true) {
45
+ if ((action === ActionType.CLICK || action === ActionType.DBLCLICK) && !askedForNative) {
41
46
  return synthetic(InputModeReason.SYNTHETIC_CLICK_PREFERRED);
42
47
  }
43
48
  if (!(await provider.isAvailableFor(session.url)))
@@ -33,6 +33,14 @@ export interface ToolDeps {
33
33
  fs: FileSystemPort;
34
34
  /** absolute.reticle path (index.ts computes cwd/.reticle). */
35
35
  reticleRoot: string;
36
+ /**
37
+ * This daemon's OWN project id, derived from the directory it was started in.
38
+ *
39
+ * Optional because a daemon above an uninitialised directory has none, and because every existing
40
+ * test construction of ToolDeps predates it. Absence means "cannot tell", which must never be
41
+ * treated as a mismatch.
42
+ */
43
+ projectId?: string;
36
44
  /** injected clock for the contract's generatedAt stamp. */
37
45
  now: () => number;
38
46
  /**
@@ -98,6 +98,19 @@ export const CORE_TOOL_NAMES = new Set([
98
98
  ReticleTool.QUERY,
99
99
  ReticleTool.ACT,
100
100
  ReticleTool.ACT_AND_WAIT,
101
+ // ACT_SEQUENCE is here because its absence was measurably CAUSING the biggest loop in the data.
102
+ //
103
+ // Measured 2026-08-10/11 (non-CI): `reticle_act` 319 calls, `reticle_act_sequence` **7**. `act`
104
+ // also leads the repeat table at 110 consecutive-repeat runs, and inside those sessions the
105
+ // repeated calls are 98 clicks and 21 fills — a login form driven one round trip at a time, which
106
+ // is exactly the antipattern SKILL.md warns about and exactly what this tool exists to collapse.
107
+ //
108
+ // The repeats are NOT retries: looping sessions have a lower error rate (0.051) than non-looping
109
+ // ones (0.078). The calls succeed and get repeated, because the batching tool was reachable only
110
+ // through `reticle_run` — so an agent had to already know it existed to use it, and 7-vs-319 says
111
+ // essentially nobody did. A tool an agent must already know about is a tool that never gets
112
+ // called; the same argument that put INSPECT and FEEDBACK here.
113
+ ReticleTool.ACT_SEQUENCE,
101
114
  ReticleTool.OBSERVE,
102
115
  ReticleTool.NETWORK,
103
116
  ReticleTool.CONSOLE,
package/docs/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # Docs index
2
+
3
+ Two audiences share this directory, which is why it looked like twenty-five unsorted files. Every page states its own audience in the first blockquote; this index is the shortcut.
4
+
5
+ **User docs are published** to [reticle.sh](https://reticle.sh) via `mint.json`. **Contributor docs are not** — they are for people working _on_ Reticle, and they live here because they belong next to what they describe.
6
+
7
+ ---
8
+
9
+ ## For people using Reticle
10
+
11
+ **Start:** [getting-started.md](getting-started.md) → [usage.md](usage.md) (the full tool reference).
12
+
13
+ | Page | What it answers |
14
+ | --- | --- |
15
+ | [getting-started.md](getting-started.md) | install it, connect an agent, verify something |
16
+ | [usage.md](usage.md) | every tool, every argument — the reference |
17
+ | [agent-cheatsheet.md](agent-cheatsheet.md) | the condensed version an agent keeps in context |
18
+ | [architecture.md](architecture.md) | how it works, and why it is built this way |
19
+ | [platform-integration.md](platform-integration.md) | Vite, Next, Remix, Astro, plain HTML |
20
+ | [desktop-apps.md](desktop-apps.md) | Electron and Tauri |
21
+ | [integration-patterns.md](integration-patterns.md) | wiring it into a real codebase |
22
+ | [flows.md](flows.md) | record → save → replay → heal |
23
+ | [testing.md](testing.md) | `@reticlehq/test` — turning a session into a CI suite |
24
+ | [multi-agent-testing.md](multi-agent-testing.md) | more than one agent on one app |
25
+ | [human-control.md](human-control.md) | taking the wheel back from the agent |
26
+ | [deploy-checks.md](deploy-checks.md) | running Reticle against a deployed build |
27
+ | [token-efficiency.md](token-efficiency.md) | why it costs less than a screenshot loop |
28
+ | [benchmarks.md](benchmarks.md) | how the numbers were measured, including where Reticle loses |
29
+ | [telemetry.md](telemetry.md) | what is collected, and how to turn it off |
30
+ | [local-registry.md](local-registry.md) | installing an unpublished build |
31
+ | [enterprise.md](enterprise.md) | the licensed surface |
32
+
33
+ ## For people working on Reticle
34
+
35
+ **Start:** [`CONTRIBUTING.md`](../CONTRIBUTING.md) → [gates.md](gates.md).
36
+
37
+ | Page | What it answers |
38
+ | --- | --- |
39
+ | [gates.md](gates.md) | **I changed some files — which gate do I run?** |
40
+ | [gate-plan.md](gate-plan.md) | why the gates are shaped this way, and what is still unbuilt |
41
+ | [system-map.md](system-map.md) | how a tool call reaches the app, and which failures are silent |
42
+ | [telemetry-contract.md](telemetry-contract.md) | required reading before touching anything that emits |
43
+ | [debugging.md](debugging.md) | debugging Reticle itself — the four signals and what each answers |
44
+ | [fixtures.md](fixtures.md) | the sibling `reticle-fixtures` repo: install complexity, not regressions |
45
+ | [matrix/README.md](matrix/README.md) | submitting an MCP-client compatibility record — the best first contribution |
46
+ | [matrix/MATRIX.md](matrix/MATRIX.md) | which clients are known to work (generated; do not hand-edit) |
47
+ | [`../apps/README.md`](../apps/README.md) | what belongs in `apps/`, and what does not |
48
+ | [`../apps/e2e/harness-rules.md`](../apps/e2e/harness-rules.md) | the four rules every gate obeys, and the incident behind each |
49
+ | [`../bench/README.md`](../bench/README.md) | what in `bench/` is live and what is a kept one-off study |
50
+
51
+ `fixtures-dispatch-receiver.yml` is not a doc — it is the workflow template `reticle-fixtures` needs, kept in this repo so the two ends of the contract cannot drift apart.
52
+
53
+ ---
54
+
55
+ **Adding a page?** Put it in the right table above. If it is user-facing, add it to `mint.json` too — a page that is published but unlisted is a page nobody finds, and a page listed here but absent from `mint.json` is one users cannot reach at all.
@@ -7,7 +7,7 @@ One screen to get fluent. Reticle is the **proof layer for AI agents** — no sc
7
7
  | Verb | Tool | One-liner |
8
8
  | --- | --- | --- |
9
9
  | **look** | `reticle_snapshot` / `reticle_query` | See the page (semantic tree) / find one specific element. |
10
- | **act** | `reticle_act` / `reticle_act_and_wait` | Click/fill a `ref` / act + wait for a predicate in one hop. |
10
+ | **act** | `reticle_act` / `reticle_act_sequence` / `reticle_act_and_wait` | Click/fill a `ref` / **batch a whole journey in one hop** / act + wait for a predicate in one hop. |
11
11
  | **observe** | `reticle_observe` / `reticle_wait_for` | Everything the app did after `since` / block until true. |
12
12
  | **assert** | `reticle_assert` | Evaluate a predicate → `{ pass, evidence, failureReason? }`. The end of every loop. |
13
13
 
@@ -50,7 +50,7 @@ A claim is real only when the layers agree. Check more than the UI:
50
50
 
51
51
  The tools advertised DIRECTLY — what you'll use 90% of the time. Everything else is one `reticle_run` away (see Token note below).
52
52
 
53
- `reticle_sessions` · `reticle_navigate` · `reticle_snapshot` · `reticle_query` · `reticle_act` · `reticle_act_and_wait` · `reticle_observe` · `reticle_network` · `reticle_console` · `reticle_wait_for` · `reticle_assert` · `reticle_state` · `reticle_inspect` (DOM node → `src/App.tsx:104`) · `reticle_feedback` (tell the maintainers what is missing) · `reticle_session` (hand back: `{action:"yield"}` the moment you stop driving, `{action:"resume"}` after a human pause).
53
+ `reticle_sessions` · `reticle_navigate` · `reticle_snapshot` · `reticle_query` · `reticle_act` · `reticle_act_sequence` · `reticle_act_and_wait` · `reticle_observe` · `reticle_network` · `reticle_console` · `reticle_wait_for` · `reticle_assert` · `reticle_state` · `reticle_inspect` (DOM node → `src/App.tsx:104`) · `reticle_feedback` (tell the maintainers what is missing) · `reticle_session` (hand back: `{action:"yield"}` the moment you stop driving, `{action:"resume"}` after a human pause).
54
54
 
55
55
  Frequently useful but NOT core, so reach them through `reticle_run({ tool, args })`: `reticle_domain` (learn the app + gaps), `reticle_capabilities` (the app's declared testable surface), `reticle_baseline {action:"diff"}`, `reticle_project` (run history).
56
56
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  > This page assumes **zero** testing background. By the end you'll understand what software testing is, why AI coding agents made it urgent, how to tell a good verification tool from a bad one, and exactly how Reticle measures up against the main alternatives — including the places Reticle loses. If a term looks like jargon, it's defined the first time it appears.
4
4
  >
5
- > _The headline charts below are rendered from the measured numbers (`assets/readme/`, rendered from the measured `history.jsonl` via `assets/benchmarks/render.mjs`); supporting diagrams live in `bench/artifacts/`._
5
+ > _Where the pictures come from, stated accurately: the **SVG charts** (`bench/artifacts/`) are generated from measured raws by `bench/harness/charts.mjs`. The **headline PNG cards** (`assets/readme/`) are screenshots of hand-authored HTML in `assets/benchmarks/src/`, rendered by `assets/benchmarks/render.mjs` — their figures are typed in by a human and are **not** read from `history.jsonl`, which this line previously claimed. Treat a card as a design asset that has to be updated by hand when a number moves, and `bench/SCORECARD.md` (plus its freshness banner) as the source of truth._
6
6
 
7
7
  ---
8
8
 
@@ -0,0 +1,76 @@
1
+ # Copy this into `reticle-fixtures` as `.github/workflows/verify-on-dispatch.yml`.
2
+ #
3
+ # It is kept HERE, in the repo that sends the dispatch, on purpose: the two halves have to agree on
4
+ # an event name and a payload shape, and a contract whose two ends live in different repositories
5
+ # drifts the first time somebody edits one of them. This file is the sending end's statement of what
6
+ # the receiving end must look like.
7
+ #
8
+ # It has never run. Nothing in this repository can verify it — the dispatch needs a token that does
9
+ # not exist yet, and the receiver needs to be committed to another repository. Treat every line as
10
+ # unverified until somebody has watched one green run, and do not let its presence here be mistaken
11
+ # for Tier 2 being live.
12
+ name: Verify a Reticle commit against the fixtures
13
+
14
+ on:
15
+ repository_dispatch:
16
+ types: [reticle-commit]
17
+ workflow_dispatch:
18
+ inputs:
19
+ sha:
20
+ description: Reticle commit to verify
21
+ required: true
22
+
23
+ jobs:
24
+ verify:
25
+ runs-on: ubuntu-latest
26
+ # Nine real apps, each installed and booted. This is why Tier 2 cannot block a merge.
27
+ timeout-minutes: 90
28
+ steps:
29
+ - name: Check out the fixtures (the pristine surface)
30
+ uses: actions/checkout@v4
31
+ with:
32
+ # `clean` is the branch that has NEVER been instrumented. Running init over an already
33
+ # wired app reports "already wired" for every step and proves nothing, which is exactly how
34
+ # an install regression ships unnoticed.
35
+ ref: clean
36
+
37
+ - name: Check out the Reticle commit under test
38
+ uses: actions/checkout@v4
39
+ with:
40
+ repository: reticlehq/reticle
41
+ ref: ${{ github.event.client_payload.sha || inputs.sha }}
42
+ path: reticle
43
+
44
+ - uses: pnpm/action-setup@v4
45
+ - uses: actions/setup-node@v4
46
+ with:
47
+ node-version: 22
48
+
49
+ - name: Build the release candidate
50
+ working-directory: reticle
51
+ run: |
52
+ pnpm install --frozen-lockfile
53
+ pnpm build
54
+
55
+ - run: npx playwright install --with-deps chromium
56
+
57
+ - name: Materialise the fixture apps
58
+ run: node scripts/materialise.mjs
59
+
60
+ - name: Install into every fixture and open each one in a browser
61
+ # `--local` is the whole point: without it the gate can only confirm what already shipped,
62
+ # which is the wrong half of the question before a release.
63
+ run: node scripts/verify.mjs --local ./reticle
64
+ env:
65
+ # The fixtures gate's own idle shutdown once fired mid-install and scored three fixtures as
66
+ # install failures. A gate owns its daemon for the whole run — see the reticle repo's
67
+ # apps/e2e/harness-rules.md, rule 2.
68
+ RETICLE_IDLE_SHUTDOWN_MS: '0'
69
+
70
+ - name: Upload the run
71
+ if: always()
72
+ uses: actions/upload-artifact@v4
73
+ with:
74
+ name: fixtures-run
75
+ path: runs/
76
+ if-no-files-found: ignore
package/docs/fixtures.md CHANGED
@@ -26,3 +26,20 @@ The last step is the one that earns its keep. Every install bug found so far has
26
26
  `apps/` — bench-app, next-smoke, the Remix and Astro examples, the Electron and Tauri smoke apps, atlas, and the API. These are the CI gates (`pnpm test:e2e`, `pnpm test:e2e:desktop`), and `integration-coverage.test.ts` fails if one of them goes missing while `SKILL.md` still offers its framework.
27
27
 
28
28
  The split is: **`apps/` proves the tools work. `reticle-fixtures` proves the install works.**
29
+
30
+ ## Wiring Tier 2 (not yet live)
31
+
32
+ CI can ask this repo to verify a Reticle commit automatically — the [`vite-ecosystem-ci`](https://github.com/vitejs/vite-ecosystem-ci) shape, where a separate repo holds the downstream apps and the main repo asks it to run. The sending half is built (`fixtures-dispatch` in `.github/workflows/ci.yml`); the receiving half is a template at [`docs/fixtures-dispatch-receiver.yml`](./fixtures-dispatch-receiver.yml).
33
+
34
+ **It is inert until two things happen, and neither can be done from inside this repository:**
35
+
36
+ 1. A fine-grained PAT with **Actions: write** on `reticlehq/reticle-fixtures`, added to `reticlehq/reticle` as the secret `FIXTURES_DISPATCH_TOKEN`. Until it exists the job logs that Tier 2 is unwired and exits 0 — it never reddens CI, because a dispatch failure has nothing to do with the change being pushed.
37
+ 2. The template copied into `reticle-fixtures` as `.github/workflows/verify-on-dispatch.yml`.
38
+
39
+ The template lives in the _sending_ repo deliberately: the two halves must agree on an event name and payload shape, and a contract whose ends live in different repositories drifts the first time somebody edits one of them.
40
+
41
+ **Nothing here has ever run.** Treat every line as unverified until somebody has watched one green run. Tier 2 being present in the plan is not Tier 2 being live.
42
+
43
+ ### Why Tier 2 exists when Tier 1 already passes
44
+
45
+ `pnpm gate:install` (Tier 1) scaffolds pristine apps and catches install **regressions**. It cannot catch install **complexity**: `rowy` is 70 dependencies of somebody else's product, `phanpy` has a 130-line vite config holding ten plugins inside a `defineConfig` callback. That is where install bugs actually hide, and a scaffold deliberately has none of it. The two tiers answer different questions and neither substitutes for the other.