@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
@@ -78,6 +78,10 @@ const RAW_TOOLS = [
78
78
  recommendation: z.string().optional(),
79
79
  }))
80
80
  .describe('Connected browser sessions with health state. `projectId` groups sessions by app (stable across port changes); `leased` marks a pool-managed headless context vs a human tab.'),
81
+ why: z
82
+ .string()
83
+ .optional()
84
+ .describe('Present ONLY when `sessions` is empty: why nothing is connected, and the next action that fixes it. An empty list is never the end of the road — read this before concluding the app cannot be driven.'),
81
85
  },
82
86
  handler: async (deps) => {
83
87
  const provider = deps.realInput;
@@ -87,6 +91,14 @@ const RAW_TOOLS = [
87
91
  realInputAvailable: provider !== undefined ? await provider.isAvailableFor(s.url) : false,
88
92
  leased: leasedIds.has(s.sessionId),
89
93
  })));
94
+ // An empty list is the most common thing this tool ever returns, and on its own it is a dead
95
+ // end: the agent asked the one question it knows to ask, got a confident-looking answer with
96
+ // no next step, and stopped. The daemon already knows WHY nothing is connected — say it here
97
+ // rather than only when a later tool fails for want of a session.
98
+ if (0 === sessions.length) {
99
+ const why = deps.sessions.noSessionHint();
100
+ return { sessions, ...(why === undefined ? {} : { why }) };
101
+ }
90
102
  return { sessions };
91
103
  },
92
104
  },
@@ -193,6 +205,10 @@ const RAW_TOOLS = [
193
205
  .string()
194
206
  .optional()
195
207
  .describe('CSS selector or element ref to restrict the search to a subtree.'),
208
+ self: z
209
+ .boolean()
210
+ .optional()
211
+ .describe('Return the `scope` element ITSELF instead of searching inside it. Use when the target is a plain layout container with no role, name, testid or text of its own — which is routinely the element that carries the handler, and is otherwise unreachable because every query excludes its own scope root. Requires `scope`.'),
196
212
  attrs: z
197
213
  .array(z.string())
198
214
  .optional()
@@ -297,7 +313,13 @@ const RAW_TOOLS = [
297
313
  // The handler forwards an explicit allowlist, so a new input is silently dropped unless it is
298
314
  // added here — the browser saw no `attrs` and returned undefined while the unit tests, which
299
315
  // call matchQuery directly, passed. Schema plus implementation is not the whole wire.
316
+ //
317
+ // It happened again with `self`, in the same session that wrote this comment: declared on the
318
+ // tool, implemented in the browser, verified by unit tests that call `runQuery` directly, and
319
+ // dropped here — so the live call returned zero matches with no error. `query-forwarding.test.ts`
320
+ // now asserts the payload, because a comment is not a guard.
300
321
  attrs: args['attrs'],
322
+ self: args['self'],
301
323
  }).then((result) => withSizeCost(paginateQueryResult(result, asNumber(args['limit']), true === args['count_only'])));
302
324
  },
303
325
  },
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Remember the private parent of a capture after the daemon has read it.
3
+ *
4
+ * Legacy captures live directly in `tmpdir()` and therefore have no directory to reclaim. The
5
+ * caller has already applied the capture-path trust-boundary check; this second exact parent check
6
+ * keeps the cleanup set limited to the one-level private layout.
7
+ */
8
+ export declare function trackCaptureDirectory(path: string): void;
9
+ /**
10
+ * Remove empty private capture directories during the daemon's existing awaited shutdown path.
11
+ *
12
+ * Deliberately `rmdir`, never recursive removal: captures are unlinked after reading, so a genuine
13
+ * directory is empty by shutdown. If anything unexpected remains, refusing to remove the directory
14
+ * is safer than widening a renderer-supplied path into a recursive delete.
15
+ */
16
+ export declare function cleanupCaptureDirectories(): Promise<void>;
@@ -0,0 +1,39 @@
1
+ import { rmdir } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { basename, dirname } from 'node:path';
4
+ import { RETICLE_CAPTURE_FILE_PREFIX } from '@reticlehq/core';
5
+ /** Private shell directories whose captures this daemon has successfully consumed. */
6
+ const captureDirectories = new Set();
7
+ /**
8
+ * Remember the private parent of a capture after the daemon has read it.
9
+ *
10
+ * Legacy captures live directly in `tmpdir()` and therefore have no directory to reclaim. The
11
+ * caller has already applied the capture-path trust-boundary check; this second exact parent check
12
+ * keeps the cleanup set limited to the one-level private layout.
13
+ */
14
+ export function trackCaptureDirectory(path) {
15
+ const parent = dirname(path);
16
+ if (dirname(parent) === tmpdir() && basename(parent).startsWith(RETICLE_CAPTURE_FILE_PREFIX)) {
17
+ captureDirectories.add(parent);
18
+ }
19
+ }
20
+ /**
21
+ * Remove empty private capture directories during the daemon's existing awaited shutdown path.
22
+ *
23
+ * Deliberately `rmdir`, never recursive removal: captures are unlinked after reading, so a genuine
24
+ * directory is empty by shutdown. If anything unexpected remains, refusing to remove the directory
25
+ * is safer than widening a renderer-supplied path into a recursive delete.
26
+ */
27
+ export async function cleanupCaptureDirectories() {
28
+ await Promise.all([...captureDirectories].map(async (dir) => {
29
+ try {
30
+ await rmdir(dir);
31
+ }
32
+ catch {
33
+ // Best-effort shutdown housekeeping. A non-empty or already-removed directory is harmless.
34
+ }
35
+ finally {
36
+ captureDirectories.delete(dir);
37
+ }
38
+ }));
39
+ }
@@ -1,4 +1,22 @@
1
1
  import type { ToolDef } from '../tools/tools.js';
2
+ /**
3
+ * Only read a capture the shell wrote. The path arrives from the PAGE, and the daemon must not
4
+ * become a file-read oracle for whatever a compromised renderer names.
5
+ *
6
+ * Two layouts are accepted, and the pair is the whole subtlety:
7
+ *
8
+ * - `<tmp>/<prefix><dir>/<prefix><n>.png` — the shell's own PRIVATE capture directory, created 0700
9
+ * so no other local user can read the screenshot or pre-place a symlink at the path we write.
10
+ * - `<tmp>/<prefix><pid>-<n>.png` — the legacy flat layout. Kept because the shell ships as a
11
+ * SEPARATE package on the user's own upgrade schedule: an app still on the older
12
+ * `@reticlehq/electron` talking to a newer daemon must keep getting screenshots, not silently
13
+ * start returning no image. It grants the daemon nothing it did not already have.
14
+ *
15
+ * Every comparison is an exact match against an UNRESOLVED path, which is what refuses traversal:
16
+ * `<tmp>/<prefix>x/../../etc/<prefix>passwd.png` has a grandparent of `<tmp>/<prefix>x/..`, not
17
+ * `<tmp>`. Exactly one directory level is allowed, so a deeper nesting is refused too.
18
+ */
19
+ export declare function isCapturePath(path: string): boolean;
2
20
  /**
3
21
  * The opt-in pixel layer. Both tools require a DRIVEN browser (reticle drive /
4
22
  * RETICLE_CDP_URL) — the always-on SDK ships no screenshotter — and return a structured NO_PROVIDER
@@ -5,6 +5,7 @@ import { sessionIdShape } from '../tools/tool-kit.js';
5
5
  import { asNumber, asRecord, asString } from '../tools/tools-helpers.js';
6
6
  import { diffPng } from './visual-diff.js';
7
7
  import { VisualStore } from './visual-store.js';
8
+ import { trackCaptureDirectory } from './capture-cleanup.js';
8
9
  import { readFile, unlink } from 'node:fs/promises';
9
10
  import { tmpdir } from 'node:os';
10
11
  import { basename, dirname } from 'node:path';
@@ -87,6 +88,7 @@ async function desktopCapture(deps, sessionId, fullPage) {
87
88
  try {
88
89
  const bytes = await readFile(path);
89
90
  await unlink(path).catch(() => undefined);
91
+ trackCaptureDirectory(path);
90
92
  return isCompletePng(bytes) ? { png: new Uint8Array(bytes) } : {};
91
93
  }
92
94
  catch {
@@ -94,12 +96,29 @@ async function desktopCapture(deps, sessionId, fullPage) {
94
96
  }
95
97
  }
96
98
  /**
97
- * Only read a capture the shell wrote: inside the OS temp dir, with Reticle's own filename prefix.
98
- * The path arrives from the PAGE, and the daemon must not become a file-read oracle for whatever a
99
- * compromised renderer names.
99
+ * Only read a capture the shell wrote. The path arrives from the PAGE, and the daemon must not
100
+ * become a file-read oracle for whatever a compromised renderer names.
101
+ *
102
+ * Two layouts are accepted, and the pair is the whole subtlety:
103
+ *
104
+ * - `<tmp>/<prefix><dir>/<prefix><n>.png` — the shell's own PRIVATE capture directory, created 0700
105
+ * so no other local user can read the screenshot or pre-place a symlink at the path we write.
106
+ * - `<tmp>/<prefix><pid>-<n>.png` — the legacy flat layout. Kept because the shell ships as a
107
+ * SEPARATE package on the user's own upgrade schedule: an app still on the older
108
+ * `@reticlehq/electron` talking to a newer daemon must keep getting screenshots, not silently
109
+ * start returning no image. It grants the daemon nothing it did not already have.
110
+ *
111
+ * Every comparison is an exact match against an UNRESOLVED path, which is what refuses traversal:
112
+ * `<tmp>/<prefix>x/../../etc/<prefix>passwd.png` has a grandparent of `<tmp>/<prefix>x/..`, not
113
+ * `<tmp>`. Exactly one directory level is allowed, so a deeper nesting is refused too.
100
114
  */
101
- function isCapturePath(path) {
102
- return dirname(path) === tmpdir() && basename(path).startsWith(RETICLE_CAPTURE_FILE_PREFIX);
115
+ export function isCapturePath(path) {
116
+ if (!basename(path).startsWith(RETICLE_CAPTURE_FILE_PREFIX))
117
+ return false;
118
+ const parent = dirname(path);
119
+ if (parent === tmpdir())
120
+ return true;
121
+ return dirname(parent) === tmpdir() && basename(parent).startsWith(RETICLE_CAPTURE_FILE_PREFIX);
103
122
  }
104
123
  /**
105
124
  * A PNG is complete only if it ends with IEND. This check exists because a truncated image once
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,10 +7,12 @@ 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_and_wait`** / `reticle_act_sequence` / `reticle_act` | **Act + name the consequence, one hop — reach for this first.** / batch a whole journey in one hop / move the app and prove nothing. |
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
 
14
+ > **Only `reticle_act_and_wait` and `reticle_assert` produce a verdict.** Everything else moves or reads the app and proves nothing, so a drive ending without one of those two has no result however many tools it used. `reticle_act` is the tool agents reach for by habit and it catches nothing; `act_and_wait` is where defects actually surface. And `verified: "unknown"` is not a pass — it means Reticle drove the app and could not tell what happened. Report it as unknown.
15
+
14
16
  `reticle_act` returns a `since` cursor — pass it to `reticle_observe({ since })` to scope the window. Elements are addressed by stable refs (`e7`) from `snapshot`/`query`; they re-resolve across re-renders.
15
17
 
16
18
  **`assert`/`wait_for` are auto-scoped to your last act.** By default they only count events buffered _since_ the most recent act, so a stale signal from a previous step can't fake a pass — pass an explicit `since` to override. **Clicks run the code, not pixels:** `reticle_act` click fires the full pointer sequence on the element (no coordinate gesture for the HUD to intercept), reports `occluded:true` when something covers the target, and stays synthetic even with CDP configured (use `args:{ native:true }` for a trusted native click).
@@ -21,6 +23,16 @@ One screen to get fluent. Reticle is the **proof layer for AI agents** — no sc
21
23
  - Need to wait without acting? `reticle_wait_for({ predicate: { kind: "settled", quietMs } })`.
22
24
  - Waiting for a specific outcome? Pass that consequence as the predicate (`{ signal }` / `{ net }`), or `allOf` it with `{ kind: "settled" }` to wait for both the event _and_ the page going quiet.
23
25
 
26
+ **A predicate that does not parse produces NO verdict — not a failing one.** Nothing runs, so the drive ends with no result at all, which is strictly worse than a failure. The shapes below are the ones agents reach for most; the first column now works, but knowing the second saves you the round trip:
27
+
28
+ | You may write | It means |
29
+ | --- | --- |
30
+ | `{ kind: "text", text: "Saved" }` | `{ kind: "text", contains: "Saved" }` |
31
+ | `{ kind: "element", role: "button", text: "Save" }` | `{ kind: "element", query: { role, text } }` |
32
+ | `{ kind: "route", url: "/checkout" }` | `{ kind: "route", contains: "/checkout" }` |
33
+
34
+ If a predicate is still rejected, the error names the fields **that kind** accepts — read it rather than guessing again, and note `state` spells its selector `path` while `route` spells its `pathname`.
35
+
24
36
  **Assert a consequence, not just presence.** `{ signal }` / `{ net }` prove the feature actually did something; `{ element }` / `{ text }` only prove something is on screen — which a stale render or a locator healed to the wrong element can fake. A _passing_ presence-only `reticle_assert` returns `advice` nudging you to a consequence; heed it on anything that matters.
25
37
 
26
38
  ## The 4-layer cross-check — never trust a green the state contradicts
@@ -50,7 +62,7 @@ A claim is real only when the layers agree. Check more than the UI:
50
62
 
51
63
  The tools advertised DIRECTLY — what you'll use 90% of the time. Everything else is one `reticle_run` away (see Token note below).
52
64
 
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).
65
+ `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
66
 
55
67
  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
68
 
@@ -71,7 +83,7 @@ Frequently useful but NOT core, so reach them through `reticle_run({ tool, args
71
83
  ## Start here
72
84
 
73
85
  0. Just ran `reticle init` / started the dev server? Poll `reticle_sessions()` until your tab appears — readiness is server-internal now, so the first live call already blocks until the SDK connects.
74
- 1. `reticle_sessions` — find the connected tab (omit `sessionId` if there's only one).
86
+ 1. `reticle_sessions` — find the connected tab (omit `sessionId` if there's only one). **An empty list is not a dead end: read the `why` field.** It names which case this is — no app running, an app running that has never dialled this daemon, a project that never went through `init`, or a tab that closed — and the fix for each. Do not fall back to static reasoning until you have read it.
75
87
  2. `reticle_domain` — learn the app BEFORE testing: the saved flows, what each asserts, and the **gaps** (declared signals/testids that no flow verifies — untested intent). Tells you what to test and where the real risk is without crawling the whole app. Falls back to `reticle_capabilities` for the raw testable surface (`testids`, `signals`, `stores`, `flows`).
76
88
  3. Run the loop: **look → act → observe → assert**, cross-checking the 4 layers on anything that matters.
77
89
 
@@ -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.
@@ -0,0 +1,98 @@
1
+ # The release gate — plan and status
2
+
3
+ > What has to be true before a PR merges and before a release ships, why each piece exists, and what is built so far. Derived from [`system-map.md`](./system-map.md); the harness rules every tier obeys are in [`harness-rules.md`](../apps/e2e/harness-rules.md).
4
+
5
+ ## Two gates, not one
6
+
7
+ | | Merge gate | Release gate |
8
+ | ------- | ---------------------------- | ------------------------------------------------- |
9
+ | Answers | does this PR break anything? | does this version work in the clients people use? |
10
+ | Runs | every PR | on a release candidate |
11
+ | Who | machines only | machines + humans |
12
+ | Budget | minutes | hours |
13
+
14
+ **They must stay separate.** A merge that waits on a human client matrix stalls every contribution until three people with three editors happen to be awake.
15
+
16
+ ## Tiers
17
+
18
+ | Tier | Question | Who | When | Blocks |
19
+ | --- | --- | --- | --- | --- |
20
+ | 0 — repo | is the code internally consistent | Actions | every PR | merge |
21
+ | 1 — install | does it install into an app nobody instrumented | Actions, scaffolded apps | PRs touching install/wire | merge |
22
+ | 2 — ecosystem | does it survive third-party apps at real scale | `reticle-fixtures`, dispatch | 3×/week + release | release |
23
+ | 3 — conformance | does each MCP client actually work | humans + headless CLIs | release candidate | the tag |
24
+ | 4 — canary | does a real model still succeed | nightly, live LLM | nightly | nothing (alerts) |
25
+
26
+ **Tier 1 vs 2.** Tier 1 scaffolds a pristine app at CI time (`npm create vite`) — clean by construction, seconds to produce, catches install _regressions_, fast enough to block a PR. Tier 2 uses vendored production apps and catches install _complexity_. Conflating them produces a gate too slow to block and too shallow to trust.
27
+
28
+ **Tier 3 is the [CNCF conformance](https://github.com/cncf/k8s-conformance) model:** contributors do not _claim_ a client works, they run one command and submit its machine-generated output as a PR, which a bot validates before a human looks. Self-reported pass/fail cannot gate anything.
29
+
30
+ **No LLM in the merge path.** Deterministic tests on every commit; live-model evals nightly and alert-only. The `record → save → verify → heal` chain is what makes this possible — a flow is recorded once by an agent, then replayed forever with no model.
31
+
32
+ ## The matrix, factored
33
+
34
+ 3 OS × 6 clients × 8 frameworks × 2 runtimes = 288 combinations. Nobody runs that. Test each axis against a fixed baseline of the others instead: 3 + 6 + 8 + 2 = **19 runs, plus ~4 deliberately chosen full-stack combos** where axis interaction is most likely (Windows+Cursor+Next, macOS+Claude Code+Tauri, …). Publish the factoring, and say plainly which interactions are untested.
35
+
36
+ ## Phases
37
+
38
+ ### Phase 0 — the harness contract and the map — **done**
39
+
40
+ Nothing below is trustworthy until a gate result can be distinguished from a gate artifact.
41
+
42
+ - [x] `docs/system-map.md` — topology, connection sequence, tool graph, fragility inventory
43
+ - [x] `apps/e2e/harness-rules.md` — the four rules, with the incidents that produced each
44
+ - [x] `apps/e2e/gate-harness.mjs` — the rules as code: `portHolders`, `freePortSafely`, `startOwnedDaemon`, `watchTransport`, `attributeOutcome`, plus a self-check
45
+ - [x] `run.mjs` and `ci.yml` no longer kill client sockets on the bridge port
46
+
47
+ ### Phase 1 — close the silent gaps already in reach
48
+
49
+ - [x] **Trace-shape assertions** — `apps/e2e/trace-shape.mjs` + `trace-shape-test`. One root span per callId, no nested span without a completed parent (the hang signature), no undeclared parentless `browser.command`, no `ok:false` without an error. Calibrated against a real 494-span run: silent there, fires on all four fault shapes.
50
+ - [x] **Daemon truthfulness (the port half)** — `daemon/port-presence.ts` gives a three-state answer (`daemon` / `foreign` / `free`) from two probes and no platform code. `serve` now refuses a foreign port and waits for a real bind before claiming success; `status` and `doctor` name the obstacle. Guarded by `daemon-port-honesty-test`, which squats the port and includes the free-port control. (#105, #112, #115)
51
+ - [x] **`daemon_alive` heartbeat** — `daemon/heartbeat.ts` beats unconditionally on a fixed cadence (30s, `RETICLE_HEARTBEAT_MS` to override) and `classifyDaemonLife` reads a log back into `alive` / `clean` / `signalled` / `died_silently` / `unknown`. A heartbeat nobody interprets is only log volume, so the reader ships with it and is exported for the gate. Guarded by `daemon-heartbeat-test`, which SIGKILLs a real daemon and includes the tidy-shutdown control. (#123)
52
+ - [x] **`observation_lost`** — a new `VerifiedReason`, matched above the `pass === false` clause, so a lost connection grades UNKNOWN instead of being reported as a failed assertion. Signalled by a structured flag from `waitForPredicate`, never by matching on `failureReason` (which is free prose about the app everywhere else it is produced). Threaded through the assert and wait paths too, not just act. (#124)
53
+ - [x] **Telemetry chokepoint coverage** — `tools/dispatch-chokepoint.test.ts` scans for any `.handler(` call outside `runTool` and fails unless it is declared with a stated reason and cost. Three exist today: family folding in `merge-tools.ts` (counted as the family, not the member), `verify-change-tools.ts` (inner `flow_verify` uncounted), and the bridge test harness. Two of those are known observability gaps — the value is that they are now _known_ and a fourth cannot arrive unnoticed. Verified by introducing a bypass and watching it redden.
54
+ - [x] **Specified transport faults** in place of `kill -9` — `apps/e2e/fault-proxy.mjs` (no dependency; toxiproxy is not installed and the battery is deliberately dependency-free) gives none / reset-peer / blackhole / latency / truncate over `node:net`, each proven distinguishable by its own self-check. `transport-faults-test` puts it between the MCP proxy and a real daemon and asserts the product claim: every call is ANSWERED and the stdio server survives. It also separates the two unanswered-call populations by timing — a queued call answered by the 20s queue timer, a call broken in flight answered in ~600ms via `sse_aborted`. A toxic breaks the connection and never the process, so the self-inflicted `kill -9` confusion is unreachable here.
55
+
56
+ ### Phase 2 — the install gate
57
+
58
+ - [x] **Tier 1 — all three `init` paths** — `apps/e2e/install-gate.mjs` (`pnpm gate:install`), wired into CI as its own `install-gate` job. Scaffolds `npm create vite`, `create-next-app` (app router) and `create-next-app --no-app` (pages router), publishes this checkout to a local Verdaccio, lets **`init` do its own dependency install** from it, boots each app, opens it in a real browser and POLLS for a session. **3/3 scaffolds, 8/8 assertions each.** The pages-router path — no `app/` root layout, so connect must mount via `pages/_app` — is the one that once did nothing at all, silently.
59
+ - `⚠` is an absolute zero, not a tolerated exemption. The earlier `file:`-wired version had to pass `--no-install` and then argue away the `⚠` it produced, which left the step most likely to regress as the one step untested.
60
+ - A `package-lock.json` check confirms the SDK came from the local registry. Without it a scope typo silently measures PUBLISHED code — which is exactly the defect this work found in `docs/local-registry.md` (`@reticle:` vs `@reticlehq:`).
61
+ - **Negative control, run FIRST in CI** (`pnpm gate:install:self-test`): every scaffold is mis-wired to a port the daemon is not on and every one must go RED. Verified — all three fail on the session assertion alone (7 passed, 1 failed), attributed FAIL rather than INCONCLUSIVE because the bridge was provably up. A guard that has never failed is not a guard.
62
+ - `file:` wiring is a dead end and the reason is worth keeping: npm symlinks it, which Vite resolves and Next does not; `--install-links` copies instead and then cannot resolve `workspace:*` at all.
63
+ - [◐] **Tier 2 — built, NOT live.** The sending half is `fixtures-dispatch` in `ci.yml` (on `main` only, never fails the workflow — it is a notification, and the verdict lands in the fixtures repo's run). The receiving half is a template at [`docs/fixtures-dispatch-receiver.yml`](./fixtures-dispatch-receiver.yml), kept in the _sending_ repo so the two ends of the contract cannot drift apart.
64
+ - **Blocked on two things neither of which can be done from inside this repo:** a fine-grained PAT with Actions:write on `reticle-fixtures` added as `FIXTURES_DISPATCH_TOKEN`, and the template copied into that repo. Until the token exists the job says so and exits 0.
65
+ - **It has never run.** Every line is unverified. Tier 2 appearing in this plan is not Tier 2 being live, and the checkbox is deliberately ◐ rather than ✗ or ✓.
66
+ - [x] **Committed baseline of what `init` plans** — `apps/e2e/install-baseline.json`, one line per step (`mark title → target`) per scaffold, diffed on every run; `--update-baseline` re-records it so a change arrives as a reviewable diff. "Zero ⚠" is an absolute and cannot carry this alone: a step silently changing mark (`✓ → ℹ`) still passes it, and a step DISAPPEARING from the plan passes it most comfortably of all, because the thing that would have warned you is the thing that is gone. A threshold answers "is this bad"; a baseline answers "is this DIFFERENT", and silent regressions are almost always the second question.
67
+ - The `target` is in the fingerprint and is the load-bearing field. Leaving it out — which the first version did, on an assumption about absolute paths that was simply wrong — made the app-router and pages-router baselines byte-identical, so a regression mounting the pages-router app into the wrong file would have diffed clean. `Mount ReticleDev → app/layout.tsx` vs `→ pages/_app.tsx` is the whole distinction.
68
+ - Proven by tampering: changing one committed entry to `vite.config.js` reddens with a readable expected/got, while every other assertion stays green — which is the point, since a one-character target change is invisible to both "zero ⚠" and "a session appeared".
69
+ - [x] **Risk routing** — a `changes` job computes the diff with plain `git diff --name-only` (no third-party action: the question is "what changed", and a paths-filter dependency for a one-line diff is a supply-chain decision for nothing) and gates `install-gate` on it. It **fails open** — an unresolvable base ref runs every tier, because the cost of a false positive is CI time and the cost of a false negative is the 0%-connect class shipping again.
70
+ - The reason this was deferred is now solved rather than ignored: a job skipped by an `if:` reports **skipped**, and a branch-protection rule requiring `install-gate` directly would block every docs-only PR forever, waiting on a job that will never run. So nothing is required directly — a single aggregate `gate` job is, and it passes when every dependency succeeded **or was deliberately skipped**, and fails on anything else. `always()` on that job is load-bearing; without it the aggregate is itself skipped whenever a dependency is, reproducing the problem one level up.
71
+ - Routing verified against twelve representative paths without touching CI.
72
+
73
+ ### Phase 3 — the client matrix
74
+
75
+ **Blocked on `init` learning each client.** Today only Claude Code (`claude mcp add`) and Cursor (`~/.cursor/mcp.json`) are first-class; everything else gets a printed JSON snippet, so a `v2.5.0-<client>` artifact would measure the contributor's copy-paste rather than the product.
76
+
77
+ - [x] **`init` support per client** — `init/mcp-clients.ts` registers Reticle with **seven** clients, not two. Every path and shape was read from that client's own documentation rather than recalled: Cursor/Windsurf/Gemini share `mcpServers`, VS Code uses `servers`, OpenCode uses `mcp` with the command as an **array**, Codex is TOML. A single "write mcp.json" assuming the Cursor shape produces a file three of them ignore — an install that reports success and registers nothing.
78
+ - Detection is conservative and one-directional: we write into a config a client **already has**, never creating `~/.gemini` or `~/.codeium` for somebody who does not use them.
79
+ - Codex is deliberately not auto-written. Editing TOML without a parser risks every _other_ server in the user's file; it returns MANUAL with an exact block.
80
+ - [x] **Per-client compat script** — `apps/e2e/client-compat.mjs`. Extracts the command from the config **exactly the way that client would**, runs it, and speaks MCP to it. 7/7 clients, 23 assertions, each advertising 18 tools.
81
+ - It proves only the half a machine can prove, and the verdict is named `runnable-unverified` so the distinction survives into the matrix. Whether the client _reads_ that path needs the real client, which is the submitted half.
82
+ - [x] **Submission flow** — `apps/e2e/matrix.mjs --validate`, run by CI's `matrix-records` job on every PR. The validator refuses what cannot be acted on: `works` without `checks.toolsVisible` (the self-report the flow exists to replace), `broken` without the client's verbatim error, or any record with no host or version. Its self-check runs first, because a validator that has never refused anything is not a validator. Contributor guide in [`docs/matrix/README.md`](./matrix/README.md).
83
+ - [x] **Generated `MATRIX.md`** — from submitted records, prettier-ignored because a generated file that fails `format:check` teaches people to skip the gate. **The current honest state is seven clients and ZERO ticks**: `◐` means a runnable entry was written where the client documents it and _nobody has run that client_, and the generated legend says so rather than rounding `◐` up to `✅`.
84
+
85
+ ### Phase 4 — rates, not booleans
86
+
87
+ Connection stability is a rate. A pass/fail cannot express "breaks a lot", and a number that is not recorded cannot regress.
88
+
89
+ - [x] **Soak with a recorded stability rate** — `apps/e2e/soak.mjs` (`pnpm gate:soak`), run inside the e2e battery where a paired app already exists, and appended to `bench/soak-history.jsonl` so the rate can regress against itself. One held-open MCP link for the whole run, never a reconnect per call (harness rule 3). First recorded row: **120/120 answered, 0 link drops over 39s**.
90
+ - **Idle time between rounds is the point**, not a pause for politeness. 150 back-to-back calls finish in seconds and measure throughput; the disconnect users actually report happens while nobody is calling — the agent is thinking and the next call finds the link gone. Keep-alives, idle shutdown and proxy timeouts all live in that gap, so a soak with no idle never enters it. `--idle-ms 30000 --rounds 60` is the half-hour release soak.
91
+ - Attribution is 3-valued: a transport that did not stay up reports INCONCLUSIVE and claims nothing about the product (harness rule 4).
92
+ - [x] **Per-tool latency and failure budgets** — same run, `bench/TOOL-PROFILE.md`. Answers "which tool is breaking": `tool-surface-sweep` proves each tool is callable **once**, and one call cannot have a failure rate.
93
+ - **Latency is recorded, NOT gated, and that is a deliberate deviation from the wording of this plan.** CLAUDE.md: timing assertions are a bug. A p95 budget in wall-clock ms is a statement about the machine, goes red only under parallel CI load, and teaches everyone to re-run — a gate people re-run has stopped working. A >4x p95 jump prints a loud WARN instead. What IS gated: answer rate (absolute) and per-tool failure rate (vs the recorded baseline), both deterministic.
94
+ - The warn carries a 50ms floor, added because the first real run shouted `reticle_sessions p95 8ms vs 1ms (>4x)` — true, imperceptible, and exactly how a warning channel dies.
95
+ - It profiles the **6 repeatable read-only tools, not all 48 advertised**, and says so in the generated file. Repeating a mutating tool measures the fixture drifting rather than the tool degrading.
96
+ - Eleven-case self-check (`node apps/e2e/soak.mjs --self-check`), in CI's `matrix-records` job. It needs no build and no app — proven by running it in an empty directory, which is how a static import of the built CLI was caught.
97
+ - [ ] false-green scorecard as a standing gate against apps we did not write (#130)
98
+ - **Genuinely blocked here, not deferred by choice.** "Apps we did not write" is the whole content of the check, and this repo contains only apps we wrote. It belongs to the fixtures repo (Tier 2) and needs `FIXTURES_DISPATCH_TOKEN`.