@reticlehq/server 2.11.0 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. package/SKILL.md +26 -2
  2. package/dist/bridge/bridge.d.ts +0 -13
  3. package/dist/bridge/bridge.js +1 -1
  4. package/dist/bridge/bridge.test-harness.d.ts +2 -0
  5. package/dist/bridge/bridge.test-harness.js +8 -1
  6. package/dist/capsule/capsule-store.d.ts +2 -1
  7. package/dist/capsule/capsule-store.js +1 -1
  8. package/dist/capsule/causal-summary.d.ts +2 -2
  9. package/dist/chromium-launch-options.d.ts +28 -0
  10. package/dist/chromium-launch-options.js +29 -0
  11. package/dist/cli/cli-doctor.js +8 -0
  12. package/dist/cli/cli-flow-commands.d.ts +3 -1
  13. package/dist/cli/cli-flow-commands.js +34 -4
  14. package/dist/cli/cli-kill.d.ts +2 -1
  15. package/dist/cli/cli-parse.d.ts +3 -1
  16. package/dist/cli/cli-parse.js +29 -7
  17. package/dist/cli/cli-update-commands.js +20 -1
  18. package/dist/cli/cli-verify.d.ts +1 -1
  19. package/dist/cli/cli-verify.js +2 -2
  20. package/dist/cli/cloud-cli.js +12 -6
  21. package/dist/cli/config-discovery.d.ts +2 -1
  22. package/dist/cli/doctor-rows.d.ts +3 -1
  23. package/dist/cli/doctor-rows.js +3 -1
  24. package/dist/cli/gate-exit.d.ts +21 -0
  25. package/dist/cli/gate-exit.js +21 -0
  26. package/dist/cli/gate-hook-message.d.ts +33 -0
  27. package/dist/cli/gate-hook-message.js +46 -0
  28. package/dist/cli/mcp-command.d.ts +25 -0
  29. package/dist/cli/mcp-command.js +143 -0
  30. package/dist/cli/sibling-ports.d.ts +37 -0
  31. package/dist/cli/sibling-ports.js +56 -0
  32. package/dist/cli.js +37 -110
  33. package/dist/cloud/cloud-config.d.ts +2 -1
  34. package/dist/cloud/cloud-sync.d.ts +11 -0
  35. package/dist/cloud/cloud-sync.js +32 -0
  36. package/dist/crawl/crawl.d.ts +9 -13
  37. package/dist/crawl/crawl.js +20 -3
  38. package/dist/daemon/daemon-resolve.d.ts +79 -0
  39. package/dist/daemon/daemon-resolve.js +118 -0
  40. package/dist/daemon/free-port.d.ts +29 -0
  41. package/dist/daemon/free-port.js +55 -0
  42. package/dist/daemon/heartbeat.d.ts +0 -4
  43. package/dist/daemon/heartbeat.js +2 -2
  44. package/dist/dev/stale-issue-guard.d.ts +0 -2
  45. package/dist/dev/stale-issue-guard.js +1 -1
  46. package/dist/events/contradictions.d.ts +41 -0
  47. package/dist/events/contradictions.js +125 -12
  48. package/dist/events/declared.d.ts +14 -0
  49. package/dist/events/declared.js +33 -0
  50. package/dist/events/echo-mismatch.js +14 -0
  51. package/dist/events/observed-in-window.d.ts +12 -0
  52. package/dist/events/observed-in-window.js +17 -0
  53. package/dist/events/predicate-asks.d.ts +13 -0
  54. package/dist/events/predicate-asks.js +22 -0
  55. package/dist/events/predicate-eval.js +64 -22
  56. package/dist/events/predicate-parse.d.ts +0 -6
  57. package/dist/events/predicate-parse.js +43 -3
  58. package/dist/events/predicate-schema.d.ts +44 -0
  59. package/dist/events/predicate-schema.js +126 -23
  60. package/dist/events/predicate.d.ts +15 -0
  61. package/dist/events/predicate.js +41 -2
  62. package/dist/flows/coverage.d.ts +0 -8
  63. package/dist/flows/coverage.js +1 -1
  64. package/dist/flows/decision.d.ts +5 -1
  65. package/dist/flows/decision.js +33 -7
  66. package/dist/flows/flake.d.ts +0 -2
  67. package/dist/flows/flake.js +1 -1
  68. package/dist/flows/flow-intent.d.ts +52 -0
  69. package/dist/flows/flow-intent.js +123 -0
  70. package/dist/flows/flow-replay-run.js +27 -4
  71. package/dist/flows/flow-success.js +15 -3
  72. package/dist/flows/flow-tools.js +32 -4
  73. package/dist/flows/flows.d.ts +6 -1
  74. package/dist/flows/flows.js +34 -22
  75. package/dist/flows/predicate-to-expect.js +6 -3
  76. package/dist/flows/server-verify.js +16 -2
  77. package/dist/flows/verify-change-tools.js +4 -1
  78. package/dist/honesty/blind-spots.d.ts +0 -17
  79. package/dist/honesty/blind-spots.js +1 -1
  80. package/dist/honesty/feature-capture.d.ts +150 -0
  81. package/dist/honesty/feature-capture.js +233 -0
  82. package/dist/honesty/instrumentation-gaps.d.ts +30 -6
  83. package/dist/honesty/instrumentation-gaps.js +61 -5
  84. package/dist/honesty/tool-hit-rate.d.ts +86 -0
  85. package/dist/honesty/tool-hit-rate.js +90 -0
  86. package/dist/honesty/undeclared-change.d.ts +51 -0
  87. package/dist/honesty/undeclared-change.js +56 -0
  88. package/dist/honesty/verified.d.ts +15 -0
  89. package/dist/honesty/verified.js +68 -4
  90. package/dist/http-server.d.ts +0 -1
  91. package/dist/http-server.js +1 -1
  92. package/dist/hunt/hunt-report.d.ts +0 -2
  93. package/dist/hunt/hunt-report.js +1 -1
  94. package/dist/impact/impact-store.d.ts +0 -5
  95. package/dist/impact/impact-store.js +1 -1
  96. package/dist/index.d.ts +4 -1
  97. package/dist/init/agent-rules.d.ts +17 -0
  98. package/dist/init/agent-rules.js +31 -1
  99. package/dist/init/capabilities.d.ts +0 -2
  100. package/dist/init/capabilities.js +1 -1
  101. package/dist/init/closing-hint.d.ts +36 -0
  102. package/dist/init/closing-hint.js +120 -0
  103. package/dist/init/confirm.d.ts +0 -25
  104. package/dist/init/confirm.js +3 -3
  105. package/dist/init/cra.d.ts +14 -1
  106. package/dist/init/cra.js +43 -12
  107. package/dist/init/dev-script.d.ts +48 -0
  108. package/dist/init/dev-script.js +63 -0
  109. package/dist/init/mcp.d.ts +16 -5
  110. package/dist/init/mcp.js +26 -10
  111. package/dist/init/plan-framework.d.ts +0 -5
  112. package/dist/init/plan-framework.js +83 -9
  113. package/dist/init/plan.d.ts +10 -30
  114. package/dist/init/plan.js +30 -15
  115. package/dist/init/refresh-rules.d.ts +39 -0
  116. package/dist/init/refresh-rules.js +51 -0
  117. package/dist/init/run.d.ts +0 -26
  118. package/dist/init/run.js +14 -87
  119. package/dist/init/snippets.js +53 -10
  120. package/dist/input/real-input.d.ts +2 -0
  121. package/dist/input/real-input.js +3 -2
  122. package/dist/input/scroll-find.d.ts +7 -4
  123. package/dist/input/scroll-find.js +63 -25
  124. package/dist/intent/inline-intent.d.ts +46 -0
  125. package/dist/intent/inline-intent.js +103 -0
  126. package/dist/intent/intent-store.d.ts +37 -0
  127. package/dist/intent/intent-store.js +112 -0
  128. package/dist/intent/intent-tools.d.ts +2 -0
  129. package/dist/intent/intent-tools.js +80 -0
  130. package/dist/intent/open-intents.d.ts +21 -0
  131. package/dist/intent/open-intents.js +25 -0
  132. package/dist/journal/envelope.d.ts +26 -26
  133. package/dist/journal/journal-recorder.d.ts +23 -0
  134. package/dist/journal/journal-recorder.js +35 -0
  135. package/dist/mcp/attach-memory.d.ts +67 -0
  136. package/dist/mcp/attach-memory.js +142 -0
  137. package/dist/mcp/mcp-proxy.d.ts +0 -7
  138. package/dist/mcp/mcp-proxy.js +14 -2
  139. package/dist/mcp/mcp.d.ts +2 -0
  140. package/dist/mcp/mcp.js +45 -23
  141. package/dist/mcp/server-instructions.js +45 -3
  142. package/dist/mcp/tool-catalog-cache.d.ts +8 -0
  143. package/dist/mcp/tool-catalog-cache.js +10 -0
  144. package/dist/oracles/flow-instrument-gaps.d.ts +4 -4
  145. package/dist/oracles/flow-instrument-gaps.js +4 -5
  146. package/dist/oracles/self-instrument.d.ts +9 -9
  147. package/dist/oracles/self-instrument.js +19 -13
  148. package/dist/pool/browser-pool.d.ts +12 -1
  149. package/dist/pool/browser-pool.js +31 -1
  150. package/dist/pool/playwright-launcher.js +3 -1
  151. package/dist/project/fs-port.d.ts +3 -0
  152. package/dist/project/fs-port.js +3 -2
  153. package/dist/project/memory-fs.js +6 -1
  154. package/dist/project/project-tools.js +3 -32
  155. package/dist/project/reticle-dir.d.ts +2 -0
  156. package/dist/project/reticle-dir.js +1 -0
  157. package/dist/runs/context-tools.d.ts +2 -0
  158. package/dist/runs/context-tools.js +89 -0
  159. package/dist/runs/run-context.d.ts +40 -0
  160. package/dist/runs/run-context.js +158 -0
  161. package/dist/runs/run-diff.d.ts +2 -1
  162. package/dist/runs/verification-sync.js +2 -2
  163. package/dist/session/dev-server-probe.d.ts +0 -8
  164. package/dist/session/dev-server-probe.js +1 -1
  165. package/dist/session/last-act.d.ts +7 -1
  166. package/dist/session/last-act.js +2 -2
  167. package/dist/session/live-control-tools.js +1 -42
  168. package/dist/session/no-session-diagnosis.d.ts +10 -0
  169. package/dist/session/no-session-diagnosis.js +24 -7
  170. package/dist/session/no-session-next-action.d.ts +5 -0
  171. package/dist/session/no-session-next-action.js +35 -3
  172. package/dist/session/no-session-watch.d.ts +8 -0
  173. package/dist/session/no-session-watch.js +82 -64
  174. package/dist/session/output-budget.js +4 -1
  175. package/dist/session/session-health.d.ts +9 -0
  176. package/dist/session/session-health.js +23 -0
  177. package/dist/session/session-manager.js +50 -1
  178. package/dist/session/session-successor.d.ts +37 -0
  179. package/dist/session/session-successor.js +78 -0
  180. package/dist/session/session.d.ts +31 -1
  181. package/dist/session/session.js +75 -2
  182. package/dist/telemetry/automation-hint.d.ts +2 -1
  183. package/dist/telemetry/feedback-outbox.d.ts +2 -1
  184. package/dist/telemetry/project-profile.d.ts +0 -8
  185. package/dist/telemetry/project-profile.js +1 -1
  186. package/dist/telemetry/run-telemetry.d.ts +0 -2
  187. package/dist/telemetry/run-telemetry.js +1 -1
  188. package/dist/telemetry/verification-of.js +2 -2
  189. package/dist/tools/act-tools.js +126 -21
  190. package/dist/tools/assert-source.d.ts +22 -0
  191. package/dist/tools/assert-source.js +53 -0
  192. package/dist/tools/assert-verdict.d.ts +20 -3
  193. package/dist/tools/assert-verdict.js +36 -4
  194. package/dist/tools/browser-tools.js +0 -20
  195. package/dist/tools/complete-snapshot.d.ts +48 -0
  196. package/dist/tools/complete-snapshot.js +75 -0
  197. package/dist/tools/coverage-tools.d.ts +10 -1
  198. package/dist/tools/coverage-tools.js +123 -69
  199. package/dist/tools/dynamic-tools.js +29 -8
  200. package/dist/tools/error-recovery.d.ts +24 -14
  201. package/dist/tools/error-recovery.js +70 -9
  202. package/dist/tools/feedback-tools.d.ts +1 -1
  203. package/dist/tools/feedback-tools.js +2 -2
  204. package/dist/tools/invoke-tool.js +62 -2
  205. package/dist/tools/lease-hint.d.ts +10 -0
  206. package/dist/tools/lease-hint.js +53 -4
  207. package/dist/tools/lease-tools.d.ts +5 -1
  208. package/dist/tools/lease-tools.js +22 -8
  209. package/dist/tools/navigate-arrival.d.ts +0 -2
  210. package/dist/tools/navigate-arrival.js +1 -1
  211. package/dist/tools/observe-tools.js +63 -26
  212. package/dist/tools/read-tools.js +5 -3
  213. package/dist/tools/real-input-attempt.d.ts +22 -0
  214. package/dist/tools/real-input-attempt.js +153 -1
  215. package/dist/tools/reconcile-tools.js +18 -4
  216. package/dist/tools/resolve-target.d.ts +0 -7
  217. package/dist/tools/sdk-marker-probe.d.ts +29 -0
  218. package/dist/tools/sdk-marker-probe.js +57 -0
  219. package/dist/tools/snapshot-delta.d.ts +1 -1
  220. package/dist/tools/tool-kit.d.ts +12 -0
  221. package/dist/tools/tool-kit.js +16 -0
  222. package/dist/tools/tool-names.d.ts +3 -0
  223. package/dist/tools/tool-names.js +3 -0
  224. package/dist/tools/tool-surface.d.ts +85 -6
  225. package/dist/tools/tool-surface.js +122 -2
  226. package/dist/tools/tools.js +22 -4
  227. package/dist/tools/unadvertised-help.js +4 -4
  228. package/dist/tools/verify-next-baton.d.ts +53 -0
  229. package/dist/tools/verify-next-baton.js +52 -0
  230. package/dist/update/update-nudge.d.ts +7 -10
  231. package/dist/update/update-nudge.js +12 -3
  232. package/dist/update/updater.d.ts +1 -1
  233. package/docs/README.md +2 -1
  234. package/docs/cli/cloud.mdx +1 -1
  235. package/docs/cli/doctor.mdx +1 -0
  236. package/docs/cli/verify.mdx +4 -5
  237. package/docs/desktop.mdx +1 -1
  238. package/docs/docs.json +2 -2
  239. package/docs/gates.md +1 -1
  240. package/docs/install-manual.mdx +1 -1
  241. package/docs/packages/browser.mdx +2 -2
  242. package/docs/packages/server.mdx +1 -1
  243. package/docs/packages.mdx +8 -6
  244. package/docs/platform-integration.md +17 -0
  245. package/docs/predicates.mdx +8 -0
  246. package/docs/reticle-manual.md +106 -0
  247. package/docs/tools-tools-and-run.mdx +2 -4
  248. package/docs/troubleshooting.mdx +9 -0
  249. package/docs/usage.md +3 -2
  250. package/package.json +2 -2
  251. package/dist/session/session-readiness.d.ts +0 -34
  252. package/dist/session/session-readiness.js +0 -36
@@ -5,14 +5,166 @@
5
5
  * Split out of act-tools.ts along its natural seam: the act tools care only about the outcome
6
6
  * (`result` defined = it went native), never about provider availability, box resolution,
7
7
  * drag-target inspection or the reason taxonomy.
8
+ *
9
+ * Also hosts `rewriteUploadArgs` — the daemon-side path that lets an agent name a file on disk
10
+ * and have its REAL bytes reach the browser's `<input type="file">`. The browser SDK cannot read
11
+ * the filesystem; the daemon can. The rewrite happens here, before the ACT command crosses the
12
+ * bridge, so the browser side sees a normal `{ content, name, type }` call and `assertUploadArgs`
13
+ * keeps its invariant (no fabricated bytes, no silently-dropped keys).
14
+ *
15
+ * Trust boundary: scoped to the project root (one level above `deps.reticleRoot`), resolved
16
+ * through `realpath` so symlinks cannot escape it. Sensitive files (.env*, .git/, *.pem, id_*,
17
+ * .npmrc) are denied with a message that says why. The cap is derived from the bridge's own
18
+ * MAX_MESSAGE_BYTES with the base64 4/3 inflation factor applied, so the encoded payload always
19
+ * fits in one WebSocket frame.
8
20
  */
9
- import { ActionType, InputModeReason, ReticleCommand } from '@reticlehq/core';
21
+ import { ActionType, InputModeReason, ReticleCommand, TRANSPORT_LIMITS } from '@reticlehq/core';
10
22
  import { isPointerAction } from '../input/real-input.js';
11
23
  import { assertDragNotDestructive, assertNotDestructive } from './act-danger.js';
12
24
  import { NATIVE_INPUT_ARG } from '@reticlehq/core';
13
25
  import { asString, asRecord } from './tools-helpers.js';
14
26
  import { commandOrThrow } from './tool-kit.js';
15
27
  import { asBox } from './act-helpers.js';
28
+ import { isAbsolute, join, relative, extname, basename } from 'node:path';
29
+ /**
30
+ * Minimal extension → MIME-type table for the file types agents most commonly upload.
31
+ * Falls back to `application/octet-stream` for anything not listed here — the browser and
32
+ * the receiving server both sniff the real type from the bytes anyway.
33
+ */
34
+ const MIME_BY_EXT = {
35
+ '.pdf': 'application/pdf',
36
+ '.csv': 'text/csv',
37
+ '.txt': 'text/plain',
38
+ '.json': 'application/json',
39
+ '.xml': 'application/xml',
40
+ '.html': 'text/html',
41
+ '.htm': 'text/html',
42
+ '.png': 'image/png',
43
+ '.jpg': 'image/jpeg',
44
+ '.jpeg': 'image/jpeg',
45
+ '.gif': 'image/gif',
46
+ '.webp': 'image/webp',
47
+ '.svg': 'image/svg+xml',
48
+ '.zip': 'application/zip',
49
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
50
+ '.xls': 'application/vnd.ms-excel',
51
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
52
+ '.doc': 'application/msword',
53
+ '.mp4': 'video/mp4',
54
+ '.mp3': 'audio/mpeg',
55
+ };
56
+ function mimeFromPath(filePath) {
57
+ return MIME_BY_EXT[extname(filePath).toLowerCase()] ?? 'application/octet-stream';
58
+ }
59
+ /**
60
+ * Sensitive path patterns that are refused even inside the project root.
61
+ *
62
+ * The project root is the right scope for fixture files. It also contains secrets that live
63
+ * inside a repo: .env*, .git/config, *.pem, id_rsa, .npmrc. An agent whose context includes
64
+ * text it just read off the page can be prompted to "verify the importer — upload your .env".
65
+ * The deny-list keeps fixtures/foo.pdf working while closing that path.
66
+ *
67
+ * Patterns are matched against the resolved basename (after realpath, so no symlink aliasing).
68
+ */
69
+ const DENIED_PATTERNS = [
70
+ /^\.env(\.|$)/i, // .env, .env.local, .env.production …
71
+ /^\.git$/i, // .git directory itself
72
+ /\.pem$/i, // TLS private keys
73
+ /^id_/i, // SSH private keys: id_rsa, id_ed25519 …
74
+ /^\.npmrc$/i, // npm auth tokens
75
+ /^\.netrc$/i, // generic credential store
76
+ /^\.aws$/i, // AWS credentials directory
77
+ ];
78
+ function isDeniedPath(resolvedPath) {
79
+ // Check every segment of the path, not just the basename, so .git/config is caught too.
80
+ const segments = resolvedPath.split(/[/\\]/);
81
+ return segments.some((seg) => DENIED_PATTERNS.some((re) => re.test(seg)));
82
+ }
83
+ /**
84
+ * Maximum raw bytes the daemon will read for an upload.
85
+ *
86
+ * The bridge serialises bytes as base64 inside a JSON WebSocket frame; base64 inflates by 4/3.
87
+ * The frame must fit within MAX_MESSAGE_BYTES (the maxPayload applied to BOTH bridge sockets).
88
+ * We leave ~25% headroom for the JSON envelope (action type, ref, other fields).
89
+ *
90
+ * Previously hardcoded at 10 MiB, which is 13× the 1 MiB frame limit — any enterprise PDF
91
+ * would have been rejected by the socket, not by this guard. Now derived from the same constant
92
+ * that configures the socket so they can never drift.
93
+ */
94
+ const UPLOAD_MAX_BYTES = Math.floor((TRANSPORT_LIMITS.MAX_MESSAGE_BYTES / (4 / 3)) * 0.75);
95
+ /**
96
+ * Resolve and validate a caller-supplied upload path.
97
+ *
98
+ * 1. Resolve to absolute (join against project root for relative paths).
99
+ * 2. Call `realpath` to follow symlinks — `relative()` is lexical and a symlink inside the tree
100
+ * can point outside it; realpath is the only reliable check.
101
+ * 3. Confirm the real path is within the project root.
102
+ * 4. Confirm the path does not match the sensitive-file deny-list.
103
+ *
104
+ * Returns the resolved real path on success. Throws with a user-readable message on any violation.
105
+ */
106
+ async function resolveUploadPath(rawPath, projectRoot, fs) {
107
+ const abs = isAbsolute(rawPath) ? rawPath : join(projectRoot, rawPath);
108
+ // realpath resolves symlinks; it also rejects ENOENT, so we get a clear missing-file error.
109
+ const real = await fs.realpath(abs).catch(() => {
110
+ throw new Error(`upload path '${rawPath}' could not be read: file not found or not accessible.`);
111
+ });
112
+ const rel = relative(projectRoot, real);
113
+ if (rel.startsWith('..') || isAbsolute(rel)) {
114
+ throw new Error(`upload path '${rawPath}' resolves to '${real}', which is outside the project root ` +
115
+ `'${projectRoot}' — only files within the project directory may be uploaded. ` +
116
+ 'Use a path relative to the project root, or an absolute path inside it.');
117
+ }
118
+ if (isDeniedPath(real)) {
119
+ throw new Error(`upload path '${rawPath}' matches a sensitive-file pattern and cannot be read by the daemon. ` +
120
+ 'Fixture files for upload tests should live in a dedicated directory (e.g. fixtures/).');
121
+ }
122
+ return real;
123
+ }
124
+ /**
125
+ * If the action is `upload` AND the inner args carry `path`, read the file from disk and rewrite
126
+ * the args to `{ content, name, type, __base64: true }` before the ACT command reaches the browser.
127
+ *
128
+ * Returns the (possibly rewritten) args object. All other actions are returned unchanged.
129
+ *
130
+ * This is the SINGLE interception point — called from session.command() so flow replay, crawl,
131
+ * and every other ACT dispatch site are covered without per-site wiring.
132
+ */
133
+ export async function rewriteUploadArgs(deps, action, innerArgs) {
134
+ if (action !== ActionType.UPLOAD)
135
+ return innerArgs;
136
+ const rawPath = asString(innerArgs['path']);
137
+ if (rawPath === undefined)
138
+ return innerArgs; // no path → browser handles as inline upload
139
+ // Project root is one level above .reticle/
140
+ const projectRoot = join(deps.reticleRoot, '..');
141
+ // Blocker 4 fix: stat FIRST, before allocating read buffer, so a huge file fails fast.
142
+ const realPath = await resolveUploadPath(rawPath, projectRoot, deps.fs);
143
+ const fileStat = await deps.fs.stat(realPath).catch(() => {
144
+ throw new Error(`upload path '${rawPath}' could not be read: file not found or not accessible.`);
145
+ });
146
+ // Blocker 2 fix: cap is now derived from TRANSPORT_LIMITS so it can never exceed the socket limit.
147
+ if (fileStat.size > UPLOAD_MAX_BYTES) {
148
+ throw new Error(`upload path '${rawPath}' is ${fileStat.size} bytes, which exceeds the ` +
149
+ `${UPLOAD_MAX_BYTES} byte upload limit (bridge frame cap). ` +
150
+ 'Split the file or pass its bytes as args.content directly.');
151
+ }
152
+ const bytes = await deps.fs.readFileBytes(realPath).catch(() => {
153
+ throw new Error(`upload path '${rawPath}' could not be read: file not found or not accessible.`);
154
+ });
155
+ // Encode as base-64 so the bytes survive JSON serialisation across the bridge.
156
+ const content = Buffer.from(bytes).toString('base64');
157
+ // Infer MIME type from the file extension; fall back to octet-stream if unknown.
158
+ const callerType = asString(innerArgs['type']);
159
+ const type = callerType ?? mimeFromPath(realPath);
160
+ // Default filename to the basename of the REAL path (post-symlink-resolution).
161
+ const callerName = asString(innerArgs['name']);
162
+ const name = callerName ?? basename(realPath);
163
+ // Strip 'path' — the browser does not know it; assertUploadArgs would refuse it.
164
+ // __base64: true tells the browser-side dispatch to decode content before File construction.
165
+ const { path: _dropped, name: _n, type: _t, ...rest } = innerArgs;
166
+ return { ...rest, content, name, type, __base64: true };
167
+ }
16
168
  /** Synthetic outcome with a diagnostic reason (provider configured but native input skipped). */
17
169
  function synthetic(reason) {
18
170
  return reason === undefined
@@ -6,6 +6,7 @@ import { salvageJson } from '../events/json-salvage.js';
6
6
  import { withControl } from '../session/control-envelope.js';
7
7
  import { asNumber, asString } from './tools-helpers.js';
8
8
  import { sessionIdShape, commandOrThrow } from './tool-kit.js';
9
+ import { readCompleteTree } from './complete-snapshot.js';
9
10
  /**
10
11
  * Does the screen agree with the data the app was given?
11
12
  *
@@ -88,11 +89,24 @@ export const RECONCILE_TOOLS = [
88
89
  : `${String(total)} response(s) matched but NONE carried a recorded body, so nothing was compared. Enable it where your app calls connect(): \`reticle.connect({ captureNetworkBodies: true })\`, then re-run`,
89
90
  });
90
91
  }
91
- const snapshot = await commandOrThrow(deps, session.id, ReticleCommand.SNAPSHOT, {
92
+ // The whole page, INCLUDING whatever the snapshot's node cap cut off. A comparison against a
93
+ // prefix of the page can miss the row that disagrees and can invent one that does not — the
94
+ // value it was looking for may simply sit past the cut.
95
+ const page = await readCompleteTree((scope) => commandOrThrow(deps, session.id, ReticleCommand.SNAPSHOT, {
92
96
  mode: SnapshotMode.FULL,
93
- });
94
- const tree = snapshot.tree;
95
- const mismatches = reconcile(bodies, 'string' === typeof tree ? tree : '');
97
+ ...(scope === undefined ? {} : { scope, includeRoot: true }),
98
+ }));
99
+ const mismatches = reconcile(bodies, page.tree);
100
+ // ASYMMETRIC on purpose. A mismatch FOUND in a partial page is still found — one witness proves
101
+ // presence, and withholding it because the rest of the page was unread helps nobody. An EMPTY
102
+ // result is the universal claim "nothing anywhere disagrees", and a partial page cannot support
103
+ // it. So the positive keeps working and only the negative is refused.
104
+ if (0 === mismatches.length && !page.complete) {
105
+ throw new Error(`the page could not be read completely — ${page.incompleteBecause ?? 'the snapshot was cut'}. ` +
106
+ 'Nothing was found to disagree with the API, but that is not the same as nothing disagreeing: ' +
107
+ 'the record that does could be in the part that was never read. Narrow the page (close overlays, ' +
108
+ 'navigate to the view holding these records) and re-run.');
109
+ }
96
110
  // A truncated body is declared whether or not anything was found in it: "no mismatches" over a
97
111
  // partially-read payload is a weaker statement than over a whole one, and the difference is
98
112
  // exactly the kind of omission this layer exists to refuse.
@@ -1,10 +1,3 @@
1
- /** One candidate the browser returned for a target query. */
2
- export interface TargetCandidate {
3
- ref?: unknown;
4
- role?: unknown;
5
- name?: unknown;
6
- visible?: unknown;
7
- }
8
1
  /** What the resolution produced: a ref to act on, or the reason there isn't one. */
9
2
  export type TargetResolution = {
10
3
  readonly kind: 'ref';
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Did the document this app served carry any Reticle at all?
3
+ *
4
+ * `LeaseEvidence.sdkMarker` was read in two places and set by nothing, so the clause that
5
+ * distinguishes "installed but not connecting" from "never installed" could never fire. That
6
+ * distinction is the whole difference between the diagnosis a new user needs and the four causes
7
+ * they were getting, every one of which presupposes the SDK is already there.
8
+ *
9
+ * The probe is a plain GET of the page that was just loaded, so it costs one localhost round trip on
10
+ * a path that has already failed and is about to print a paragraph.
11
+ *
12
+ * ## Every uncertainty resolves to silence, on purpose
13
+ *
14
+ * `true` requires the EXACT marker the Vite plugin injects into `<head>` of every served document —
15
+ * a constant this repo already owns, so there is no new contract and no guessing.
16
+ *
17
+ * `false` requires the served document to contain no `reticle` anywhere, in any case. That is the
18
+ * only evidence strong enough to say "this app ships no SDK", and it is deliberately harder to earn
19
+ * than `true` is: a false `false` accuses a working install, and a false `true` SUPPRESSES the one
20
+ * instruction an uninstrumented user needs. Both are worse than saying nothing.
21
+ *
22
+ * Anything else — a fetch that failed, a timeout, a document that mentions Reticle without the
23
+ * marker (a Next or Babel install, where the SDK arrives through a module and never touches the
24
+ * HTML) — returns `undefined`, and the hint says nothing about markers at all.
25
+ */
26
+ /** Pure: the verdict for a document body we managed to read. Exported so the rule is testable. */
27
+ export declare function readSdkMarker(body: string): boolean | undefined;
28
+ /** Never throws, never hangs. A probe that can break the diagnosis is worse than no probe. */
29
+ export declare function probeSdkMarker(url: string): Promise<boolean | undefined>;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Did the document this app served carry any Reticle at all?
3
+ *
4
+ * `LeaseEvidence.sdkMarker` was read in two places and set by nothing, so the clause that
5
+ * distinguishes "installed but not connecting" from "never installed" could never fire. That
6
+ * distinction is the whole difference between the diagnosis a new user needs and the four causes
7
+ * they were getting, every one of which presupposes the SDK is already there.
8
+ *
9
+ * The probe is a plain GET of the page that was just loaded, so it costs one localhost round trip on
10
+ * a path that has already failed and is about to print a paragraph.
11
+ *
12
+ * ## Every uncertainty resolves to silence, on purpose
13
+ *
14
+ * `true` requires the EXACT marker the Vite plugin injects into `<head>` of every served document —
15
+ * a constant this repo already owns, so there is no new contract and no guessing.
16
+ *
17
+ * `false` requires the served document to contain no `reticle` anywhere, in any case. That is the
18
+ * only evidence strong enough to say "this app ships no SDK", and it is deliberately harder to earn
19
+ * than `true` is: a false `false` accuses a working install, and a false `true` SUPPRESSES the one
20
+ * instruction an uninstrumented user needs. Both are worse than saying nothing.
21
+ *
22
+ * Anything else — a fetch that failed, a timeout, a document that mentions Reticle without the
23
+ * marker (a Next or Babel install, where the SDK arrives through a module and never touches the
24
+ * HTML) — returns `undefined`, and the hint says nothing about markers at all.
25
+ */
26
+ import { RETICLE_RENDER_PREHOOK } from '@reticlehq/core';
27
+ /** A page that has not answered in this long is not going to inform a diagnosis. */
28
+ const PROBE_TIMEOUT_MS = 1_500;
29
+ /** Enough of the document to carry a head-prepended script; a whole SPA bundle is not wanted. */
30
+ const PROBE_MAX_BYTES = 200_000;
31
+ /** Pure: the verdict for a document body we managed to read. Exported so the rule is testable. */
32
+ export function readSdkMarker(body) {
33
+ if (body.includes(RETICLE_RENDER_PREHOOK))
34
+ return true;
35
+ // Case-insensitive and deliberately broad: ANY mention is enough to withhold the accusation.
36
+ if (!/reticle/i.test(body))
37
+ return false;
38
+ return undefined;
39
+ }
40
+ /** Never throws, never hangs. A probe that can break the diagnosis is worse than no probe. */
41
+ export async function probeSdkMarker(url) {
42
+ const controller = new AbortController();
43
+ const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
44
+ try {
45
+ const res = await fetch(url, { signal: controller.signal });
46
+ if (!res.ok)
47
+ return undefined;
48
+ const body = (await res.text()).slice(0, PROBE_MAX_BYTES);
49
+ return readSdkMarker(body);
50
+ }
51
+ catch {
52
+ return undefined;
53
+ }
54
+ finally {
55
+ clearTimeout(timer);
56
+ }
57
+ }
@@ -26,7 +26,7 @@ interface SnapshotDelta {
26
26
  removedCount: number;
27
27
  }
28
28
  /** Where focus moved, when it moved. Absent fields mean nothing held focus on that side. */
29
- export interface FocusChange {
29
+ interface FocusChange {
30
30
  from?: string;
31
31
  to?: string;
32
32
  }
@@ -130,6 +130,18 @@ export interface ToolDef<Ext = unknown> {
130
130
  */
131
131
  handler(deps: ToolDeps<Ext>, args: Record<string, unknown>): Promise<unknown>;
132
132
  }
133
+ /**
134
+ * The optional inline intent, on every tool that draws a verdict.
135
+ *
136
+ * One field, carrying either prose or the id of an intent already declared, because the surface is
137
+ * the scarce thing and a second field for the reference case would double the cost of the idea to
138
+ * make one of its two uses marginally more explicit. Told apart by the ledger — see
139
+ * `intent/inline-intent.ts`.
140
+ *
141
+ * The first sentence is what a lean surface advertises, so it carries both readings; the rest is for
142
+ * the profiles that send the whole description.
143
+ */
144
+ export declare const intentArg: z.ZodOptional<z.ZodString>;
133
145
  export declare const sessionIdShape: {
134
146
  sessionId: z.ZodOptional<z.ZodString>;
135
147
  };
@@ -7,6 +7,22 @@
7
7
  import { z } from 'zod';
8
8
  import { ReticleCommand, SnapshotMode } from '@reticlehq/core';
9
9
  import { normalizeLines } from '../project/baselines.js';
10
+ /**
11
+ * The optional inline intent, on every tool that draws a verdict.
12
+ *
13
+ * One field, carrying either prose or the id of an intent already declared, because the surface is
14
+ * the scarce thing and a second field for the reference case would double the cost of the idea to
15
+ * make one of its two uses marginally more explicit. Told apart by the ledger — see
16
+ * `intent/inline-intent.ts`.
17
+ *
18
+ * The first sentence is what a lean surface advertises, so it carries both readings; the rest is for
19
+ * the profiles that send the whole description.
20
+ */
21
+ export const intentArg = z
22
+ .string()
23
+ .optional()
24
+ .describe('What this change is meant to make true, in your own words, or the id of an intent you already declared. ' +
25
+ 'Recorded in .reticle/intent.json, the same ledger reticle_intent writes, and marked proved by this verdict if it passes.');
10
26
  export const sessionIdShape = {
11
27
  sessionId: z
12
28
  .string()
@@ -47,6 +47,9 @@ export declare const ReticleTool: {
47
47
  readonly COVERAGE: "reticle_coverage";
48
48
  readonly VERIFY_CHANGE: "reticle_verify_change";
49
49
  /** Merged: change/flows/affected/coverage/crawl — "what is proved, and what is not". */
50
+ readonly INTENT: "reticle_intent";
51
+ /** what THIS run has established, proved, and not yet discharged — pulled when memory is gone. */
52
+ readonly CONTEXT: "reticle_context";
50
53
  readonly VERIFY: "reticle_verify";
51
54
  /** read cross-run history (.reticle/project.json) + diff-vs-last for a name. */
52
55
  readonly PROJECT: "reticle_project";
@@ -48,6 +48,9 @@ export const ReticleTool = {
48
48
  COVERAGE: 'reticle_coverage',
49
49
  VERIFY_CHANGE: 'reticle_verify_change',
50
50
  /** Merged: change/flows/affected/coverage/crawl — "what is proved, and what is not". */
51
+ INTENT: 'reticle_intent',
52
+ /** what THIS run has established, proved, and not yet discharged — pulled when memory is gone. */
53
+ CONTEXT: 'reticle_context',
51
54
  VERIFY: 'reticle_verify',
52
55
  /** read cross-run history (.reticle/project.json) + diff-vs-last for a name. */
53
56
  PROJECT: 'reticle_project',
@@ -70,6 +70,45 @@ export declare const TOOL_SURFACE: {
70
70
  * supplies the evidence the surface would otherwise have to go and find.
71
71
  */
72
72
  readonly VERIFY: "verify";
73
+ /**
74
+ * EXPERIMENTAL, opt-in, and UNDER MEASUREMENT. Not a recommendation, and not on a path to becoming
75
+ * the default until it has a number of its own.
76
+ *
77
+ * It exists because of the finding recorded on VERIFY above, read forwards instead of backwards.
78
+ * That surface cut the bill 37% and TRIPLED false alarms, and the five new false alarms were not
79
+ * scattered: every one of them was a defect whose evidence lives in `reticle_state`,
80
+ * `reticle_network` or `reticle_observe`. So the token saving and the accuracy loss came from two
81
+ * different cuts, and only one of them has to be paid for. `lean` keeps every observation tool and
82
+ * cuts the rest.
83
+ *
84
+ * What stays, and why each one is not a candidate for removal:
85
+ * SNAPSHOT / QUERY look. Without them the agent cannot name an element it did not already know.
86
+ * STATE / NETWORK / CONSOLE / OBSERVE the four evidence tools. Dropping these is the MEASURED
87
+ * cause of the tripled false-alarm rate. They are the point of the profile.
88
+ * ACT_AND_WAIT the only tool that acts AND returns a verdict. Omitting `until` makes it
89
+ * act-then-settle, which is what `reticle_act` does, so `act` is redundant here.
90
+ * ASSERT the only way to get a verdict WITHOUT acting. `act_and_wait` requires an
91
+ * `action`, so this is a capability it genuinely cannot express, not a synonym.
92
+ *
93
+ * What goes, and what it costs (one `reticle_run` hop each — `unadvertisedToolHelp` hands the agent
94
+ * the exact call, so a dropped name never comes back as "not found"):
95
+ * ACT subsumed: `act_and_wait` with `until` omitted is act-then-settle.
96
+ * WAIT_FOR subsumed: `act_and_wait { until }` takes the same PredicateSchema.
97
+ * ACT_SEQUENCE batching is a cost optimisation on top of a surface built to be cheap.
98
+ * NAVIGATE once per run, not once per turn — the wrong thing to pay for every turn.
99
+ * SESSIONS the CLI answers this (`reticle status`) without spending any turn budget.
100
+ * INSPECT fix-side, not verdict-side: it is called once a bug is FOUND, so its hop is
101
+ * paid once per finding rather than once per turn. Nothing else maps a node to
102
+ * a source file, which is why it is a hop and not a deletion.
103
+ * SESSION the lease block and the pause hint name it in prose, which is what made it
104
+ * load-bearing on the default surface. Under a trimmed surface the
105
+ * unadvertised-tool help turns that into a working `reticle_run` call.
106
+ * FEEDBACK the one drop with a KNOWN CEILING: an unadvertised feedback channel collects
107
+ * little to nothing, so a long-lived profile must not ship without it. Acceptable
108
+ * only because this profile is opt-in and short-lived by construction; if it
109
+ * ever stops being an experiment, feedback comes back first.
110
+ */
111
+ readonly LEAN: "lean";
73
112
  };
74
113
  export type ToolSurface = (typeof TOOL_SURFACE)[keyof typeof TOOL_SURFACE];
75
114
  /**
@@ -79,8 +118,6 @@ export type ToolSurface = (typeof TOOL_SURFACE)[keyof typeof TOOL_SURFACE];
79
118
  * users to shop among alternatives that did not meaningfully differ.
80
119
  */
81
120
  export declare const ADVERTISE_ALL_ENV = "RETICLE_ADVERTISE_ALL_TOOLS";
82
- /** Opt into the smallest verdict-capable surface. Read by the DAEMON at startup, like the others. */
83
- export declare const VERIFY_SURFACE_ENV = "RETICLE_VERIFY_SURFACE";
84
121
  /**
85
122
  * The retired setting, still read so nobody's shell profile breaks.
86
123
  *
@@ -98,6 +135,17 @@ export declare const CORE_TOOL_NAMES: ReadonlySet<string>;
98
135
  * 48 by itself can push a user's other servers out or be dropped wholesale. The budget is a count,
99
136
  * so no amount of trimming parameter prose buys anything back — only advertising fewer names does.
100
137
  *
138
+ * That is true of the COUNT cap and false of everything else, which is worth separating here before
139
+ * somebody reads it as "prose is free". Measured against two competitor MCP servers on the same
140
+ * agent loop: this server advertises the FEWEST tools of the three and ships the HEAVIEST schema
141
+ * block, and 83% of that weight is parameter descriptions, not tool descriptions. The block is
142
+ * re-sent every turn, so it is multiplied by turn count before one byte of evidence is counted, and
143
+ * it was most of the gap in that run.
144
+ *
145
+ * Which does NOT license trimming by eye. Those descriptions are what make a tool get called
146
+ * correctly, and one malformed call costs a whole extra turn — more than the bytes saved. The
147
+ * outcome to measure is turn count and error rate, not size.
148
+ *
101
149
  * Everything omitted stays in the registry, stays catalogued by `reticle_tools`, and stays callable
102
150
  * by name through `reticle_run { tool, args }`. The cost is one discovery hop on the cold tail, which
103
151
  * is the same trade the default surface has always made, applied one level further out.
@@ -113,11 +161,42 @@ export declare const CORE_TOOL_NAMES: ReadonlySet<string>;
113
161
  */
114
162
  export declare const EXTENDED_TOOL_NAMES: ReadonlySet<string>;
115
163
  /**
116
- * The verify surface: one acting tool that returns a verdict, plus the two meta-tools that reach
117
- * everything else. `act_and_wait` can resolve its own target, so no query tool is needed to name an
118
- * element — that round trip was half the token cost of a verification.
164
+ * The lean surface: look, observe, act-with-a-verdict, assert. Pinned by lean-surface.test.ts,
165
+ * because an experiment whose independent variable drifts mid-flight measures nothing. Every
166
+ * inclusion and every exclusion is argued at TOOL_SURFACE.LEAN.
167
+ *
168
+ * ## MEASURED: it is cheaper and less correct. Do not promote it to the default.
169
+ *
170
+ * Run as its own arm of the fix-and-verify benchmark — same five bugs, same model, same budget, the
171
+ * full surface as its control:
172
+ *
173
+ * | | full surface | lean |
174
+ * |---|---|---|
175
+ * | bugs fixed | **5/5** | **3/5** |
176
+ * | FALSE GREENS | **0** | **1** |
177
+ *
178
+ * The false green is the finding, and it is the first this benchmark has ever produced. On
179
+ * `broken-form-validation` the lean agent finished in SIX turns and 62k tokens — the cheapest cell
180
+ * of the whole run, a fifth of what the full surface spent — edited the file, took one snapshot,
181
+ * and ended with a hypothetical walkthrough: "Enter spaces + valid name -> spaces are trimmed,
182
+ * valid part is used. VERDICT: FIXED". The submit button was still enabled for a whitespace-only
183
+ * service. It reasoned about what its own code would now do instead of driving it, which is exactly
184
+ * the failure this product exists to catch, produced by our own surface.
185
+ *
186
+ * The other loss has the same root from the other side. On `cross-component-regression` the agent
187
+ * called `reticle_tools` three times and `reticle_run` eight, saying "since Reticle isn't
188
+ * connecting, let me see what reticle_tools offers" — it spent the run hunting for capabilities
189
+ * this surface does not advertise instead of using them, and never fixed the bug.
190
+ *
191
+ * That is the same result the `verify` surface already recorded — dropping the observation tools
192
+ * TRIPLED false alarms — arriving from the other direction: strip the surface and the agent stops
193
+ * verifying, then claims anyway. The known ceiling written here was that `reticle_feedback` goes
194
+ * uncollected. This is a bigger one and it was not predicted.
195
+ *
196
+ * It stays opt-in and it stays measured. The one thing it genuinely bought — the ceiling-bound cell
197
+ * halved, 30 turns to 14 — is not worth a verdict that lies, because the product is the verdict.
119
198
  */
120
- export declare const VERIFY_TOOL_NAMES: ReadonlySet<string>;
199
+ export declare const LEAN_TOOL_NAMES: ReadonlySet<string>;
121
200
  /**
122
201
  * Which surface is live.
123
202
  *