@reticlehq/server 2.10.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 (290) hide show
  1. package/SKILL.md +73 -11
  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 +41 -0
  45. package/dist/dev/stale-issue-guard.js +75 -0
  46. package/dist/domain/domain-tools.js +3 -2
  47. package/dist/events/contradictions.d.ts +41 -0
  48. package/dist/events/contradictions.js +125 -12
  49. package/dist/events/declared.d.ts +14 -0
  50. package/dist/events/declared.js +33 -0
  51. package/dist/events/echo-mismatch.js +14 -0
  52. package/dist/events/observed-in-window.d.ts +12 -0
  53. package/dist/events/observed-in-window.js +17 -0
  54. package/dist/events/predicate-asks.d.ts +30 -0
  55. package/dist/events/predicate-asks.js +48 -0
  56. package/dist/events/predicate-eval.js +254 -25
  57. package/dist/events/predicate-parse.d.ts +0 -6
  58. package/dist/events/predicate-parse.js +51 -5
  59. package/dist/events/predicate-schema.d.ts +62 -0
  60. package/dist/events/predicate-schema.js +162 -4
  61. package/dist/events/predicate.d.ts +15 -0
  62. package/dist/events/predicate.js +41 -2
  63. package/dist/flows/coverage.d.ts +0 -8
  64. package/dist/flows/coverage.js +1 -1
  65. package/dist/flows/decision.d.ts +5 -1
  66. package/dist/flows/decision.js +33 -7
  67. package/dist/flows/flake.d.ts +0 -2
  68. package/dist/flows/flake.js +1 -1
  69. package/dist/flows/flow-intent.d.ts +52 -0
  70. package/dist/flows/flow-intent.js +123 -0
  71. package/dist/flows/flow-replay-run.js +27 -4
  72. package/dist/flows/flow-success.js +15 -3
  73. package/dist/flows/flow-tools.js +65 -8
  74. package/dist/flows/flows.d.ts +6 -1
  75. package/dist/flows/flows.js +34 -22
  76. package/dist/flows/predicate-to-expect.js +14 -3
  77. package/dist/flows/server-verify.js +16 -2
  78. package/dist/flows/verify-change-tools.js +9 -2
  79. package/dist/honesty/blind-spots.d.ts +8 -16
  80. package/dist/honesty/blind-spots.js +18 -5
  81. package/dist/honesty/feature-capture.d.ts +150 -0
  82. package/dist/honesty/feature-capture.js +233 -0
  83. package/dist/honesty/gap-ledger.d.ts +52 -0
  84. package/dist/honesty/gap-ledger.js +63 -0
  85. package/dist/honesty/instrumentation-gaps.d.ts +79 -0
  86. package/dist/honesty/instrumentation-gaps.js +77 -0
  87. package/dist/honesty/observability.d.ts +37 -0
  88. package/dist/honesty/observability.js +40 -0
  89. package/dist/honesty/tool-hit-rate.d.ts +86 -0
  90. package/dist/honesty/tool-hit-rate.js +90 -0
  91. package/dist/honesty/undeclared-change.d.ts +51 -0
  92. package/dist/honesty/undeclared-change.js +56 -0
  93. package/dist/honesty/verified.d.ts +15 -0
  94. package/dist/honesty/verified.js +68 -4
  95. package/dist/http-server.d.ts +0 -1
  96. package/dist/http-server.js +1 -1
  97. package/dist/hunt/hunt-report.d.ts +0 -2
  98. package/dist/hunt/hunt-report.js +1 -1
  99. package/dist/impact/impact-store.d.ts +0 -5
  100. package/dist/impact/impact-store.js +1 -1
  101. package/dist/index.d.ts +4 -2
  102. package/dist/index.js +41 -0
  103. package/dist/init/agent-rules.d.ts +17 -0
  104. package/dist/init/agent-rules.js +31 -1
  105. package/dist/init/capabilities.d.ts +0 -2
  106. package/dist/init/capabilities.js +1 -1
  107. package/dist/init/closing-hint.d.ts +36 -0
  108. package/dist/init/closing-hint.js +120 -0
  109. package/dist/init/confirm.d.ts +0 -25
  110. package/dist/init/confirm.js +3 -3
  111. package/dist/init/cra.d.ts +14 -1
  112. package/dist/init/cra.js +43 -12
  113. package/dist/init/dev-script.d.ts +48 -0
  114. package/dist/init/dev-script.js +63 -0
  115. package/dist/init/mcp.d.ts +16 -5
  116. package/dist/init/mcp.js +26 -10
  117. package/dist/init/node-io.js +3 -0
  118. package/dist/init/plan-framework.d.ts +0 -5
  119. package/dist/init/plan-framework.js +83 -9
  120. package/dist/init/plan.d.ts +10 -30
  121. package/dist/init/plan.js +30 -15
  122. package/dist/init/refresh-rules.d.ts +39 -0
  123. package/dist/init/refresh-rules.js +51 -0
  124. package/dist/init/run.d.ts +2 -10
  125. package/dist/init/run.js +26 -68
  126. package/dist/init/slash-command.d.ts +1 -1
  127. package/dist/init/slash-command.js +33 -10
  128. package/dist/init/snippets.js +53 -10
  129. package/dist/input/real-input.d.ts +2 -0
  130. package/dist/input/real-input.js +3 -2
  131. package/dist/input/scroll-find.d.ts +7 -4
  132. package/dist/input/scroll-find.js +63 -25
  133. package/dist/intent/inline-intent.d.ts +46 -0
  134. package/dist/intent/inline-intent.js +103 -0
  135. package/dist/intent/intent-store.d.ts +37 -0
  136. package/dist/intent/intent-store.js +112 -0
  137. package/dist/intent/intent-tools.d.ts +2 -0
  138. package/dist/intent/intent-tools.js +80 -0
  139. package/dist/intent/open-intents.d.ts +21 -0
  140. package/dist/intent/open-intents.js +25 -0
  141. package/dist/journal/envelope.d.ts +2 -2
  142. package/dist/journal/journal-recorder.d.ts +23 -0
  143. package/dist/journal/journal-recorder.js +35 -0
  144. package/dist/license/license.d.ts +8 -2
  145. package/dist/license/license.js +10 -15
  146. package/dist/mcp/attach-memory.d.ts +67 -0
  147. package/dist/mcp/attach-memory.js +142 -0
  148. package/dist/mcp/mcp-proxy.d.ts +0 -7
  149. package/dist/mcp/mcp-proxy.js +14 -2
  150. package/dist/mcp/mcp.d.ts +2 -0
  151. package/dist/mcp/mcp.js +45 -23
  152. package/dist/mcp/server-instructions.js +45 -3
  153. package/dist/mcp/tool-catalog-cache.d.ts +8 -0
  154. package/dist/mcp/tool-catalog-cache.js +10 -0
  155. package/dist/oracles/flow-instrument-gaps.d.ts +4 -4
  156. package/dist/oracles/flow-instrument-gaps.js +4 -5
  157. package/dist/oracles/self-instrument.d.ts +9 -9
  158. package/dist/oracles/self-instrument.js +19 -13
  159. package/dist/pool/browser-pool.d.ts +12 -1
  160. package/dist/pool/browser-pool.js +31 -1
  161. package/dist/pool/playwright-launcher.js +3 -1
  162. package/dist/project/artifact-root.d.ts +83 -0
  163. package/dist/project/artifact-root.js +112 -0
  164. package/dist/project/fs-port.d.ts +3 -0
  165. package/dist/project/fs-port.js +3 -2
  166. package/dist/project/memory-fs.d.ts +9 -0
  167. package/dist/project/memory-fs.js +93 -0
  168. package/dist/project/project-store.d.ts +17 -0
  169. package/dist/project/project-store.js +33 -0
  170. package/dist/project/project-tools.js +3 -32
  171. package/dist/project/remember-project.d.ts +24 -0
  172. package/dist/project/remember-project.js +40 -0
  173. package/dist/project/reticle-dir.d.ts +2 -0
  174. package/dist/project/reticle-dir.js +1 -0
  175. package/dist/project/session-root.d.ts +15 -0
  176. package/dist/project/session-root.js +31 -0
  177. package/dist/runs/context-tools.d.ts +2 -0
  178. package/dist/runs/context-tools.js +89 -0
  179. package/dist/runs/run-context.d.ts +40 -0
  180. package/dist/runs/run-context.js +158 -0
  181. package/dist/runs/run-diff.d.ts +2 -1
  182. package/dist/runs/run-tools.js +2 -1
  183. package/dist/runs/verification-sync.js +2 -2
  184. package/dist/session/dev-server-probe.d.ts +0 -8
  185. package/dist/session/dev-server-probe.js +1 -1
  186. package/dist/session/last-act.d.ts +7 -1
  187. package/dist/session/last-act.js +2 -2
  188. package/dist/session/lease-availability.d.ts +40 -0
  189. package/dist/session/lease-availability.js +43 -0
  190. package/dist/session/live-control-tools.js +1 -42
  191. package/dist/session/no-session-diagnosis.d.ts +20 -0
  192. package/dist/session/no-session-diagnosis.js +41 -12
  193. package/dist/session/no-session-next-action.d.ts +5 -0
  194. package/dist/session/no-session-next-action.js +35 -3
  195. package/dist/session/no-session-watch.d.ts +8 -0
  196. package/dist/session/no-session-watch.js +83 -65
  197. package/dist/session/output-budget.js +4 -1
  198. package/dist/session/session-health.d.ts +9 -0
  199. package/dist/session/session-health.js +23 -0
  200. package/dist/session/session-manager.js +50 -1
  201. package/dist/session/session-successor.d.ts +37 -0
  202. package/dist/session/session-successor.js +78 -0
  203. package/dist/session/session.d.ts +40 -1
  204. package/dist/session/session.js +84 -2
  205. package/dist/session/stall-clock.d.ts +18 -0
  206. package/dist/session/stall-clock.js +48 -0
  207. package/dist/telemetry/automation-hint.d.ts +2 -1
  208. package/dist/telemetry/daemon-telemetry.js +12 -23
  209. package/dist/telemetry/feedback-outbox.d.ts +2 -1
  210. package/dist/telemetry/project-profile.d.ts +0 -8
  211. package/dist/telemetry/project-profile.js +1 -1
  212. package/dist/telemetry/run-telemetry.d.ts +0 -2
  213. package/dist/telemetry/run-telemetry.js +1 -1
  214. package/dist/telemetry/session-metrics.d.ts +1 -1
  215. package/dist/telemetry/session-metrics.js +1 -1
  216. package/dist/telemetry/telemetry.d.ts +12 -3
  217. package/dist/telemetry/telemetry.js +25 -5
  218. package/dist/telemetry/verification-of.js +2 -2
  219. package/dist/tools/act-tools.js +159 -24
  220. package/dist/tools/assert-source.d.ts +22 -0
  221. package/dist/tools/assert-source.js +53 -0
  222. package/dist/tools/assert-verdict.d.ts +21 -2
  223. package/dist/tools/assert-verdict.js +59 -4
  224. package/dist/tools/browser-tools.js +0 -20
  225. package/dist/tools/complete-snapshot.d.ts +48 -0
  226. package/dist/tools/complete-snapshot.js +75 -0
  227. package/dist/tools/contract-tools.js +5 -3
  228. package/dist/tools/coverage-tools.d.ts +10 -1
  229. package/dist/tools/coverage-tools.js +124 -37
  230. package/dist/tools/dynamic-tools.js +29 -8
  231. package/dist/tools/error-recovery.d.ts +24 -14
  232. package/dist/tools/error-recovery.js +70 -9
  233. package/dist/tools/feedback-tools.d.ts +1 -1
  234. package/dist/tools/feedback-tools.js +2 -2
  235. package/dist/tools/invoke-tool.js +62 -2
  236. package/dist/tools/lease-hint.d.ts +10 -0
  237. package/dist/tools/lease-hint.js +53 -4
  238. package/dist/tools/lease-tools.d.ts +5 -1
  239. package/dist/tools/lease-tools.js +22 -8
  240. package/dist/tools/navigate-arrival.d.ts +0 -2
  241. package/dist/tools/navigate-arrival.js +1 -1
  242. package/dist/tools/observe-tools.js +70 -27
  243. package/dist/tools/read-tools.js +5 -3
  244. package/dist/tools/real-input-attempt.d.ts +22 -0
  245. package/dist/tools/real-input-attempt.js +153 -1
  246. package/dist/tools/reconcile-tools.js +18 -4
  247. package/dist/tools/resolve-target.d.ts +0 -7
  248. package/dist/tools/sdk-marker-probe.d.ts +29 -0
  249. package/dist/tools/sdk-marker-probe.js +57 -0
  250. package/dist/tools/snapshot-delta.d.ts +1 -1
  251. package/dist/tools/tool-kit.d.ts +28 -1
  252. package/dist/tools/tool-kit.js +16 -0
  253. package/dist/tools/tool-names.d.ts +3 -0
  254. package/dist/tools/tool-names.js +3 -0
  255. package/dist/tools/tool-surface.d.ts +85 -6
  256. package/dist/tools/tool-surface.js +122 -2
  257. package/dist/tools/tools.js +22 -4
  258. package/dist/tools/unadvertised-help.js +4 -4
  259. package/dist/tools/verify-next-baton.d.ts +53 -0
  260. package/dist/tools/verify-next-baton.js +52 -0
  261. package/dist/update/update-nudge.d.ts +7 -10
  262. package/dist/update/update-nudge.js +12 -3
  263. package/dist/update/updater.d.ts +1 -1
  264. package/docs/README.md +3 -1
  265. package/docs/cli/cloud.mdx +1 -1
  266. package/docs/cli/doctor.mdx +1 -0
  267. package/docs/cli/verify.mdx +4 -5
  268. package/docs/desktop.mdx +1 -1
  269. package/docs/docs.json +3 -3
  270. package/docs/enterprise.md +3 -1
  271. package/docs/gates.md +1 -1
  272. package/docs/install-manual.mdx +1 -1
  273. package/docs/license-activation.mdx +145 -0
  274. package/docs/packages/browser.mdx +2 -2
  275. package/docs/packages/server.mdx +1 -1
  276. package/docs/packages.mdx +8 -6
  277. package/docs/platform-integration.md +17 -0
  278. package/docs/predicates.mdx +8 -0
  279. package/docs/reticle-manual.md +106 -0
  280. package/docs/telemetry-contract.md +1 -12
  281. package/docs/telemetry-events.mdx +1 -34
  282. package/docs/telemetry.md +0 -1
  283. package/docs/tools-tools-and-run.mdx +2 -4
  284. package/docs/troubleshooting.mdx +9 -0
  285. package/docs/usage.md +3 -2
  286. package/package.json +2 -2
  287. package/dist/session/session-readiness.d.ts +0 -34
  288. package/dist/session/session-readiness.js +0 -36
  289. package/dist/telemetry/instrumentation-stall.d.ts +0 -57
  290. package/dist/telemetry/instrumentation-stall.js +0 -103
@@ -24,4 +24,4 @@ export declare const CURSOR_COMMAND_PATH = ".cursor/commands/reticle.md";
24
24
  * are told apart.
25
25
  */
26
26
  export declare const SLASH_COMMAND_SIGNATURE = "description: Verify this app in the browser with Reticle";
27
- export declare const SLASH_COMMAND_BODY = "---\ndescription: Verify this app in the browser with Reticle \u2014 drive one real flow and report what happened.\n---\n\nVerify this running app with Reticle. Drive it; do not read the code and guess.\n\n## Step 0 \u2014 is anything connected?\n\nCall `reticle_sessions` first.\n\n**A session is listed?** Go to \"Pick ONE flow\".\n\n**No session?** Setup is not finished, and stopping to ask the user to run something is not an\nanswer. Work these in order, and say which one you are on:\n\n0. **Is anything serving the app at all?** If a dev server is already listening, use it and go to\n step 1 \u2014 the cause is then almost always that the SDK never loaded in the page, not a missing\n server, so do not tell the user to start one they are already running. If nothing is listening:\n\n**If no dev server is listening, start one yourself.** Read the project's own dev script out of `package.json` (`dev`, `start`, whatever this project calls it), run it in the BACKGROUND, tell the user in one line that it is running and how to stop it, then carry on. Stopping to ask is how a verification turn ends with nothing verified.\n\nFive guards, none optional:\n\n1. **Never start a second one.** If something is already listening on the app's port, use it.\n2. **Never guess the command.** It comes from `package.json` scripts. If there is no recognisable dev script, say so and stop rather than inventing one.\n3. **Never kill anything.** Not a dev server, not a daemon, not a port holder \u2014 including one you started.\n4. **Background it, and say so.** The user must know a server is running and how to stop it. A dev server the human does not know about is the same failure one step later.\n5. **The permission prompt belongs to your host.** Never try to bypass, suppress or auto-approve it, and take a refusal as the answer.\n\nThen, once something is serving the app:\n\n1. **Is the SDK actually in the app?** Read the app's entry file (`src/main.tsx`, `app/layout.tsx`,\n `src/app.vue`, whatever this project uses). You are looking for an `import` from\n `@reticlehq/react` or `@reticlehq/browser` and a `connect()` call, or the Reticle plugin in\n `vite.config.*`, or `withReticle` in `next.config.*`. **If it is not there, that is the bug.**\n Wire it in \u2014 see the framework snippets at https://docs.reticle.sh/instrumentation \u2014 then restart\n the dev server and re-check.\n2. **Is the connect guarded on `hostname === 'localhost'`?** If so, remove that guard. It is false on\n any non-localhost dev host, and `window` does not exist during SSR, so the guard silently prevents\n the connect and logs nothing. Use the framework's dev flag instead (`import.meta.env.DEV`,\n `process.env.NODE_ENV !== 'production'`, `import.meta.dev`).\n3. **Is the dev server serving that entry?** Ask the user to open the app in a browser and check its\n console for a line starting `[Reticle]`. That line, or its absence, tells you which side is at\n fault. Reticle itself never starts a dev server \u2014 starting it is your job, under the guards above.\n4. **Do both sides agree on the port?** Run `npx @reticlehq/server doctor` and compare the daemon\n port against the port the page dials.\n\nIf you are still stuck after all four, file it: `reticle_feedback` with kind `gap`, saying what you\nchecked and what you saw. Then tell the user exactly which step is blocked. Do not report the install\nas finished.\n\n## Pick ONE flow\n\nNot the whole app. Pick the single most important flow you can complete in a handful of steps \u2014 the\none a user would do first (sign in, search, add to cart, submit the form). If the user named a flow or\njust changed some code, use that one instead.\n\nState which flow you picked in one line before you start.\n\n## Drive it where the human can see\n\n1. `reticle_snapshot` \u2014 read the accessibility tree and locate the elements the flow needs. Elements\n are addressable by role and name; you do **not** need `data-testid` to drive them.\n2. Walk the flow with `reticle_act_and_wait`, one step at a time. Narrate each step first \u2014 the human\n is watching the page, and the HUD shows what you say.\n3. After each step, check the effect with `reticle_assert` \u2014 not just that the click dispatched, but\n that the thing it was supposed to do actually happened.\n4. Finish with `reticle_console` and `reticle_network` to catch errors the DOM does not show.\n\n**Only `reticle_act_and_wait` and `reticle_assert` produce a verdict.** A run that ends on a\nsnapshot has proved nothing, however many tools it used.\n\n## Report\n\n- What you drove, step by step, and what each step actually produced.\n- Anything broken, with the `file:line` Reticle gave you.\n- If a step could not be verified, say so plainly. \"Unknown\" is a real answer; a green verdict you\n cannot back is not.\n\nDo not weaken or skip an assertion to make the run pass \u2014 that is a finding, not a fix.\n\n## Two things afterwards\n\n- **Anything about Reticle itself that was wrong, missing, confusing, or expensive?** Send it with\n `reticle_feedback` now, before your context is gone. That report is the only signal that decides\n what gets fixed. Bugs in the app under test are Reticle working; those go in your answer to the user.\n- **If the verification actually succeeded**, add one line for the human: Reticle is open source at\n https://github.com/reticlehq/reticle, and a star helps other people find it. One line, once. Skip it\n entirely if the run did not produce a verdict.\n";
27
+ export declare const SLASH_COMMAND_BODY = "---\ndescription: Verify this app in the browser with Reticle \u2014 drive one real flow and report what happened.\n---\n\nVerify this running app with Reticle. Drive it; do not read the code and guess.\n\n## Step 0 \u2014 is anything connected?\n\nCall `reticle_sessions` first.\n\n**A session is listed?** Go to \"Pick ONE flow\".\n\n**No session?** Setup is not finished, and stopping to ask the user to run something is not an\nanswer. Every step below is yours to do without them. Work these in order, and say which one you are\non, one line each:\n\n0. **Is Reticle even wired into this project?** `cat .reticle.json`. If it is not there, this\n project was never set up: run `npx @reticlehq/server@latest init`,\n fix every `\u26A0` and `\u2139` it prints, and carry on. Do not ask first, and do not ask which\n framework or port \u2014 both come out of the repo you are sitting in.\n\n1. **Is anything serving the app at all?** If a dev server is already listening, use it and go to\n step 2 \u2014 the cause is then almost always that the SDK never loaded in the page, not a missing\n server, so do not tell the user to start one they are already running. If nothing is listening:\n\n**If no dev server is listening, start one yourself.** Read the project's own dev script out of `package.json` (`dev`, `start`, whatever this project calls it), run it in the BACKGROUND, tell the user in one line that it is running and how to stop it, then carry on. Stopping to ask is how a verification turn ends with nothing verified.\n\nFive guards, none optional:\n\n1. **Never start a second one.** If something is already listening on the app's port, use it.\n2. **Never guess the command.** It comes from `package.json` scripts. If there is no recognisable dev script, say so and stop rather than inventing one.\n3. **Never kill anything.** Not a dev server, not a daemon, not a port holder \u2014 including one you started.\n4. **Background it, and say so.** The user must know a server is running and how to stop it. A dev server the human does not know about is the same failure one step later.\n5. **The permission prompt belongs to your host.** Never try to bypass, suppress or auto-approve it, and take a refusal as the answer.\n\n2. **Is a page open on it?** Do not ask the user to open a browser \u2014 open it:\n\n ```bash\n npx @reticlehq/server open <the url the dev server is serving>\n ```\n\n That reuses an already-connected tab or opens a new one, and waits for the page to register. On a\n machine with no browser to open, take a tab Reticle owns instead:\n `reticle_run({ tool: \"reticle_lease\", args: { action: \"acquire\", url: \"<the same url>\" } })`.\n\nThen, once something is serving the app and a page is on it:\n\n3. **Is the SDK actually in the app?** Read the app's entry file (`src/main.tsx`, `app/layout.tsx`,\n `src/app.vue`, whatever this project uses). You are looking for an `import` from\n `@reticlehq/react` or `@reticlehq/browser` and a `connect()` call, or the Reticle plugin in\n `vite.config.*`, or `withReticle` in `next.config.*`. **If it is not there, that is the bug.**\n Wire it in \u2014 see the framework snippets at https://docs.reticle.sh/instrumentation \u2014 then restart\n the dev server and re-check.\n4. **Is the connect guarded on `hostname === 'localhost'`?** If so, remove that guard. It is false on\n any non-localhost dev host, and `window` does not exist during SSR, so the guard silently prevents\n the connect and logs nothing. Use the framework's dev flag instead (`import.meta.env.DEV`,\n `process.env.NODE_ENV !== 'production'`, `import.meta.dev`).\n5. **Is the dev server serving that entry?** Look in `reticle_console`, or in the page you opened in\n step 2, for a line starting `[Reticle]`. That line, or its absence, tells you which side is at\n fault. Reticle itself never starts a dev server \u2014 starting it is your job, under the guards above.\n6. **Do both sides agree on the port?** Run `npx @reticlehq/server doctor` and compare the daemon\n port against the port the page dials.\n\nIf you are still stuck after all of those, file it: `reticle_feedback` with kind `gap`, saying what you\nchecked and what you saw. Then tell the user exactly which step is blocked. Do not report the install\nas finished.\n\n## Pick ONE flow\n\nNot the whole app. Pick the single most important flow you can complete in a handful of steps \u2014 the\none a user would do first (sign in, search, add to cart, submit the form). If the user named a flow or\njust changed some code, use that one instead.\n\nState which flow you picked in one line before you start.\n\n## Drive it where the human can see\n\n1. `reticle_snapshot` \u2014 read the accessibility tree and locate the elements the flow needs. Elements\n are addressable by role and name; you do **not** need `data-testid` to drive them.\n2. Walk the flow with `reticle_act_and_wait`, one step at a time. Narrate each step first \u2014 the human\n is watching the page, and the HUD shows what you say.\n3. After each step, check the effect with `reticle_assert` \u2014 not just that the click dispatched, but\n that the thing it was supposed to do actually happened.\n4. Finish with `reticle_console` and `reticle_network` to catch errors the DOM does not show.\n\n**Only `reticle_act_and_wait` and `reticle_assert` produce a verdict.** A run that ends on a\nsnapshot has proved nothing, however many tools it used.\n\n## Report\n\n- What you drove, step by step, and what each step actually produced.\n- Anything broken, with the `file:line` Reticle gave you.\n- If a step could not be verified, say so plainly. \"Unknown\" is a real answer; a green verdict you\n cannot back is not.\n\nDo not weaken or skip an assertion to make the run pass \u2014 that is a finding, not a fix.\n\n## If the user handed you a license key\n\nAppend `RETICLE_LICENSE_KEY=<key>` to the project's `.env` (create it if absent), make sure `.env`\nis in `.gitignore`, and confirm with `npx @reticlehq/server license`. The CLI folds a project-local\n`.env` in on every invocation, so nothing else changes. Never echo the key back, and never put it in\na commit, a comment, or a feedback report.\n\n## Two things afterwards\n\n- **Anything about Reticle itself that was wrong, missing, confusing, or expensive?** Send it with\n `reticle_feedback` now, before your context is gone. That report is the only signal that decides\n what gets fixed. Bugs in the app under test are Reticle working; those go in your answer to the user.\n- **If the verification actually succeeded**, add one line for the human: Reticle is open source at\n https://github.com/reticlehq/reticle, and a star helps other people find it. One line, once. Skip it\n entirely if the run did not produce a verdict.\n";
@@ -38,33 +38,49 @@ Call \`reticle_sessions\` first.
38
38
  **A session is listed?** Go to "Pick ONE flow".
39
39
 
40
40
  **No session?** Setup is not finished, and stopping to ask the user to run something is not an
41
- answer. Work these in order, and say which one you are on:
41
+ answer. Every step below is yours to do without them. Work these in order, and say which one you are
42
+ on, one line each:
42
43
 
43
- 0. **Is anything serving the app at all?** If a dev server is already listening, use it and go to
44
- step 1 — the cause is then almost always that the SDK never loaded in the page, not a missing
44
+ 0. **Is Reticle even wired into this project?** \`cat .reticle.json\`. If it is not there, this
45
+ project was never set up: run \`npx @reticlehq/server@latest init\`,
46
+ fix every \`⚠\` and \`ℹ\` it prints, and carry on. Do not ask first, and do not ask which
47
+ framework or port — both come out of the repo you are sitting in.
48
+
49
+ 1. **Is anything serving the app at all?** If a dev server is already listening, use it and go to
50
+ step 2 — the cause is then almost always that the SDK never loaded in the page, not a missing
45
51
  server, so do not tell the user to start one they are already running. If nothing is listening:
46
52
 
47
53
  ${DEV_SERVER_POLICY}
48
54
 
49
- Then, once something is serving the app:
55
+ 2. **Is a page open on it?** Do not ask the user to open a browser — open it:
56
+
57
+ \`\`\`bash
58
+ npx @reticlehq/server open <the url the dev server is serving>
59
+ \`\`\`
60
+
61
+ That reuses an already-connected tab or opens a new one, and waits for the page to register. On a
62
+ machine with no browser to open, take a tab Reticle owns instead:
63
+ \`reticle_run({ tool: "reticle_lease", args: { action: "acquire", url: "<the same url>" } })\`.
50
64
 
51
- 1. **Is the SDK actually in the app?** Read the app's entry file (\`src/main.tsx\`, \`app/layout.tsx\`,
65
+ Then, once something is serving the app and a page is on it:
66
+
67
+ 3. **Is the SDK actually in the app?** Read the app's entry file (\`src/main.tsx\`, \`app/layout.tsx\`,
52
68
  \`src/app.vue\`, whatever this project uses). You are looking for an \`import\` from
53
69
  \`@reticlehq/react\` or \`@reticlehq/browser\` and a \`connect()\` call, or the Reticle plugin in
54
70
  \`vite.config.*\`, or \`withReticle\` in \`next.config.*\`. **If it is not there, that is the bug.**
55
71
  Wire it in — see the framework snippets at https://docs.reticle.sh/instrumentation — then restart
56
72
  the dev server and re-check.
57
- 2. **Is the connect guarded on \`hostname === 'localhost'\`?** If so, remove that guard. It is false on
73
+ 4. **Is the connect guarded on \`hostname === 'localhost'\`?** If so, remove that guard. It is false on
58
74
  any non-localhost dev host, and \`window\` does not exist during SSR, so the guard silently prevents
59
75
  the connect and logs nothing. Use the framework's dev flag instead (\`import.meta.env.DEV\`,
60
76
  \`process.env.NODE_ENV !== 'production'\`, \`import.meta.dev\`).
61
- 3. **Is the dev server serving that entry?** Ask the user to open the app in a browser and check its
62
- console for a line starting \`[Reticle]\`. That line, or its absence, tells you which side is at
77
+ 5. **Is the dev server serving that entry?** Look in \`reticle_console\`, or in the page you opened in
78
+ step 2, for a line starting \`[Reticle]\`. That line, or its absence, tells you which side is at
63
79
  fault. Reticle itself never starts a dev server — starting it is your job, under the guards above.
64
- 4. **Do both sides agree on the port?** Run \`npx @reticlehq/server doctor\` and compare the daemon
80
+ 6. **Do both sides agree on the port?** Run \`npx @reticlehq/server doctor\` and compare the daemon
65
81
  port against the port the page dials.
66
82
 
67
- If you are still stuck after all four, file it: \`reticle_feedback\` with kind \`gap\`, saying what you
83
+ If you are still stuck after all of those, file it: \`reticle_feedback\` with kind \`gap\`, saying what you
68
84
  checked and what you saw. Then tell the user exactly which step is blocked. Do not report the install
69
85
  as finished.
70
86
 
@@ -98,6 +114,13 @@ snapshot has proved nothing, however many tools it used.
98
114
 
99
115
  Do not weaken or skip an assertion to make the run pass — that is a finding, not a fix.
100
116
 
117
+ ## If the user handed you a license key
118
+
119
+ Append \`RETICLE_LICENSE_KEY=<key>\` to the project's \`.env\` (create it if absent), make sure \`.env\`
120
+ is in \`.gitignore\`, and confirm with \`npx @reticlehq/server license\`. The CLI folds a project-local
121
+ \`.env\` in on every invocation, so nothing else changes. Never echo the key back, and never put it in
122
+ a commit, a comment, or a feedback report.
123
+
101
124
  ## Two things afterwards
102
125
 
103
126
  - **Anything about Reticle itself that was wrong, missing, confusing, or expensive?** Send it with
@@ -2,7 +2,7 @@
2
2
  * Generated file contents and copy-paste snippets for `reticle init`. Kept as named constants/builders
3
3
  * so the runner never inlines free strings.
4
4
  */
5
- import { RETICLE_DEFAULT_PORT, ReticleDir, bridgeWsUrl } from '@reticlehq/core';
5
+ import { RETICLE_DEFAULT_PORT, ReticleDir, bridgeWsUrl, RETICLE_CLIENT_HOST, RETICLE_WS_PATH, } from '@reticlehq/core';
6
6
  import { UiLibrary } from './detect.js';
7
7
  import { SERVER_VERSION } from '../version/server-version.js';
8
8
  /**
@@ -17,9 +17,9 @@ import { SERVER_VERSION } from '../version/server-version.js';
17
17
  *
18
18
  * jsDelivr rather than esm.sh, measured: a third of the requests and a third of the bytes for the
19
19
  * same result. `/+esm` is what makes it work, and the bare package URL does NOT: every file in
20
- * `dist` carries bare specifiers for `@reticlehq/core` and `@testing-library/dom`, so an unbundled
21
- * entry point dies on the first import. That is also why adding `unpkg`/`jsdelivr` fields to
22
- * package.json would not help.
20
+ * `dist` still carries bare workspace specifiers such as `@reticlehq/core`, so an unbundled entry
21
+ * point dies on the first import. That is also why adding `unpkg`/`jsdelivr` fields to package.json
22
+ * would not help.
23
23
  *
24
24
  * PINNED to this server's version on purpose. A floating import upgrades the page SDK underneath a
25
25
  * daemon that did not move, which is `version_skew` arriving by a route nothing checks.
@@ -151,7 +151,10 @@ export function ReticleDev() {
151
151
  // the root makes source paths repo-relative instead of absolute.
152
152
  const token = process.env.NEXT_PUBLIC_RETICLE_TOKEN;
153
153
  const root = process.env.NEXT_PUBLIC_RETICLE_ROOT;
154
- reticle.connect({ ${fields}...(token ? { token } : {}), ...(root ? { root } : {}) });
154
+ // withReticle() finds the daemon serving this project on every dev-server start. It wins over
155
+ // any port written into this file at install time, so moving the daemon needs no edit here.
156
+ const url = process.env.NEXT_PUBLIC_RETICLE_URL;
157
+ reticle.connect({ ${fields}...(url ? { url } : {}), ...(token ? { token } : {}), ...(root ? { root } : {}) });
155
158
 
156
159
  // ── Start with ONE flow. ──────────────────────────────────────────────────────────────────
157
160
  // Registering a store is the highest-value line here: it lets the agent check what the app
@@ -204,11 +207,51 @@ layoutPath) {
204
207
  ? `\n url: '${bridgeWsUrl(port)}',`
205
208
  : '';
206
209
  const id = projectId !== undefined && projectId.length > 0 ? `\n projectId: '${projectId}',` : '';
210
+ // Astro owns its Vite instance, so it has a build-time channel of its own: the same `define` that
211
+ // already inlines the token can inline the daemon URL, resolved every time the config is read —
212
+ // that is, on every `astro dev`. The port above is written once at install time and goes stale the
213
+ // moment the daemon moves; this does not.
214
+ //
215
+ // Needs the projectId to know WHICH daemon is ours. Without one there is nothing to match on, and
216
+ // adopting a daemon serving another project would report its state as this app's, so the whole
217
+ // block is omitted rather than made to guess.
218
+ const canDiscover = projectId !== undefined && projectId.length > 0;
219
+ // Interpolated from core rather than typed into the template: this is the same wire string
220
+ // `bridgeWsUrl` builds, and a generator that spells it out by hand is exactly how the four call
221
+ // sites that constant exists to unify drifted apart in the first place.
222
+ const RETICLE_CLIENT_HOST_LITERAL = `ws://${RETICLE_CLIENT_HOST}:`;
223
+ const urlHelper = canDiscover
224
+ ? `
225
+ // The live daemon serving THIS project, re-read on every \`astro dev\`. Same rule as the Vite and
226
+ // Next plugins: skip dead daemons, match on projectId, lowest port wins, '' when none matches.
227
+ function reticleUrl() {
228
+ const dir = process.env['RETICLE_PAIRING_TOKEN_DIR'] || join(homedir(), '.reticle');
229
+ let best;
230
+ try {
231
+ for (const file of readdirSync(dir)) {
232
+ if (!file.startsWith('daemon-') || !file.endsWith('.json')) continue;
233
+ let entry;
234
+ try { entry = JSON.parse(readFileSync(join(dir, file), 'utf8')); } catch { continue; }
235
+ if (entry.projectId !== '${projectId}') continue;
236
+ try { process.kill(entry.pid, 0); } catch { continue; }
237
+ if (best === undefined || entry.port < best) best = entry.port;
238
+ }
239
+ } catch { return ''; }
240
+ return best === undefined ? '' : '${RETICLE_CLIENT_HOST_LITERAL}' + best + '${RETICLE_WS_PATH}';
241
+ }
242
+ `
243
+ : '';
244
+ const urlDefine = canDiscover ? `\n __RETICLE_URL__: JSON.stringify(reticleUrl()),` : '';
245
+ const urlRead = canDiscover
246
+ ? `\n const url = typeof __RETICLE_URL__ !== 'undefined' ? __RETICLE_URL__ : '';`
247
+ : '';
248
+ // After the baked port, so the discovered one wins: later key in an object literal.
249
+ const urlSpread = canDiscover ? `\n ...(url.length > 0 ? { url } : {}),` : '';
207
250
  return `Astro renders its own HTML, so the connect goes in a page <script> and the pairing token is inlined by the config.
208
251
 
209
252
  1. In astro.config.mjs — inline the daemon's token and raise the build target:
210
253
 
211
- import { readFileSync } from 'node:fs';
254
+ import { readFileSync, readdirSync } from 'node:fs';
212
255
  import { homedir } from 'node:os';
213
256
  import { join } from 'node:path';
214
257
 
@@ -216,14 +259,14 @@ layoutPath) {
216
259
  const dir = process.env['RETICLE_PAIRING_TOKEN_DIR'] || join(homedir(), '.reticle');
217
260
  try { return readFileSync(join(dir, 'pairing-token'), 'utf8').trim(); } catch { return ''; }
218
261
  }
219
-
262
+ ${urlHelper}
220
263
  export default defineConfig({
221
264
  vite: {
222
265
  // Astro's default target down-levels the modern SDK bundle and fails on a destructuring transform.
223
266
  build: { target: 'es2022' },
224
267
  optimizeDeps: { esbuildOptions: { target: 'es2022' } },
225
268
  define: {
226
- __RETICLE_TOKEN__: JSON.stringify(reticleToken()),
269
+ __RETICLE_TOKEN__: JSON.stringify(reticleToken()),${urlDefine}
227
270
  // Without this, source pointers come back as absolute paths from YOUR machine — useless in a
228
271
  // report. Every other framework gets it from its build plugin; Astro owns its Vite instance.
229
272
  __RETICLE_ROOT__: JSON.stringify(process.cwd()),
@@ -236,10 +279,10 @@ ${layoutHost(layoutPath)}
236
279
  <script>
237
280
  if (import.meta.env.DEV) {
238
281
  const token = typeof __RETICLE_TOKEN__ !== 'undefined' ? __RETICLE_TOKEN__ : '';
239
- const root = typeof __RETICLE_ROOT__ !== 'undefined' ? __RETICLE_ROOT__ : '';
282
+ const root = typeof __RETICLE_ROOT__ !== 'undefined' ? __RETICLE_ROOT__ : '';${urlRead}
240
283
  const { reticle, install } = await import('@reticlehq/react');
241
284
  install();
242
- reticle.connect({${id}${extra}
285
+ reticle.connect({${id}${extra}${urlSpread}
243
286
  ...(token.length > 0 ? { token } : {}),
244
287
  ...(root.length > 0 ? { root } : {}),
245
288
  });
@@ -171,6 +171,8 @@ export interface LaunchedProviderOptions {
171
171
  */
172
172
  onNetworkDetail?: (detail: NetworkDetail) => void;
173
173
  }
174
+ /** The only place the dynamic value import of Playwright lives for the launched (drive) path. */
175
+ export declare const launchedChromium: LaunchFn;
174
176
  /**
175
177
  * Launches and OWNS a Playwright Chromium, navigates it to `driveUrl`, then drives native
176
178
  * input on that page. Headless-capable so @reticlehq/test / CI can run hover/drag unattended.
@@ -1,3 +1,4 @@
1
+ import { chromiumLaunchOptions } from '../chromium-launch-options.js';
1
2
  import { gotoOptions } from '../pool/playwright-launcher.js';
2
3
  import { BrowserLaunchKind } from '@reticlehq/core';
3
4
  import { getSessionMetrics } from '../telemetry/session-metrics.js';
@@ -220,7 +221,7 @@ export class CdpRealInputProvider {
220
221
  }
221
222
  const INJECT_CONNECT_WAIT_MS = 8_000;
222
223
  /** The only place the dynamic value import of Playwright lives for the launched (drive) path. */
223
- const launchedChromium = async (headless) => {
224
+ export const launchedChromium = async (headless) => {
224
225
  let mod;
225
226
  try {
226
227
  mod = await import('playwright');
@@ -230,7 +231,7 @@ const launchedChromium = async (headless) => {
230
231
  }
231
232
  const settle = getSessionMetrics().recordConnectAttempt(BrowserLaunchKind.LAUNCHED);
232
233
  try {
233
- const browser = await mod.chromium.launch({ headless });
234
+ const browser = await mod.chromium.launch(chromiumLaunchOptions(headless));
234
235
  settle();
235
236
  return browser;
236
237
  }
@@ -36,10 +36,13 @@ export interface ScrollFindResult {
36
36
  note?: string;
37
37
  }
38
38
  /**
39
- * Reveal an element that a windowed/virtualized list has not mounted yet. Queries
40
- * once (it may already be visible), then scrolls the container ~one viewport at a time, re-querying
41
- * after each, until the element appears, the list ends, or the maxScrolls budget is spent. Pure
42
- * orchestration over the session command seam — fully unit-testable with a fake.
39
+ * Reveal an element that a windowed/virtualized list has not mounted yet. Queries once (it may
40
+ * already be visible), optionally jumps straight to a bisection estimate, then walks the container
41
+ * a viewport at a time re-querying after each step. Downward first; when the bottom is reached the
42
+ * walk TURNS AROUND and continues upward, because a list that is already scrolled to its end keeps
43
+ * its earlier rows mounted above the viewport and a downward-only search answered "exhausted" on
44
+ * its first step (#505). `exhausted` therefore means BOTH directions are spent, not "the bottom is
45
+ * here". Pure orchestration over the session command seam — fully unit-testable with a fake.
43
46
  */
44
47
  export declare function scrollToFind(session: ScrollFindSession, q: ScrollFindQuery, opts?: {
45
48
  maxScrolls?: number;
@@ -13,61 +13,99 @@ async function queryFirst(session, q) {
13
13
  return undefined;
14
14
  }
15
15
  /**
16
- * A note attached to `{ found: false, exhausted: true }` only when the failure is
17
- * actually informative: the document itself had nothing to scroll (scrolled:false with
18
- * no explicit container), so the target is likely inside a list with its own scroll
19
- * container. Reaching the genuine end of a scrolled container stays quiet.
16
+ * A note attached to `{ found: false, exhausted: true }` only when the failure is actually
17
+ * informative: nothing in the container ever moved (so the "list" being searched was never
18
+ * scrollable and the target is likely inside one with its own scroll container). Reaching the
19
+ * genuine ends of a container that DID scroll stays quiet — that is exhaustion, not confusion.
20
20
  */
21
- function exhaustNote(q, data) {
21
+ function exhaustNote(q, data, nothingEverScrolled) {
22
22
  if (q.container !== undefined)
23
23
  return {};
24
- if (false !== data['scrolled'])
24
+ if (true === data['scrolled'] || !nothingEverScrolled)
25
25
  return {};
26
26
  return {
27
27
  note: "The document did not scroll — the target may be inside a list with its own scroll container. Pass that container's ref as `container`.",
28
28
  };
29
29
  }
30
+ /** One viewport-height step upward when the last result carried no clientHeight to size it by. */
31
+ const UP_STEP_FALLBACK_PX = 400;
30
32
  /**
31
- * Reveal an element that a windowed/virtualized list has not mounted yet. Queries
32
- * once (it may already be visible), then scrolls the container ~one viewport at a time, re-querying
33
- * after each, until the element appears, the list ends, or the maxScrolls budget is spent. Pure
34
- * orchestration over the session command seam — fully unit-testable with a fake.
33
+ * Size the upward step off the container the SCROLL result just described, falling back to a
34
+ * plain viewport height when the result did not carry one.
35
+ */
36
+ function upStepPx(last) {
37
+ const h = last['clientHeight'];
38
+ return 'number' === typeof h && h > 0 ? h : UP_STEP_FALLBACK_PX;
39
+ }
40
+ /**
41
+ * Reveal an element that a windowed/virtualized list has not mounted yet. Queries once (it may
42
+ * already be visible), optionally jumps straight to a bisection estimate, then walks the container
43
+ * a viewport at a time re-querying after each step. Downward first; when the bottom is reached the
44
+ * walk TURNS AROUND and continues upward, because a list that is already scrolled to its end keeps
45
+ * its earlier rows mounted above the viewport and a downward-only search answered "exhausted" on
46
+ * its first step (#505). `exhausted` therefore means BOTH directions are spent, not "the bottom is
47
+ * here". Pure orchestration over the session command seam — fully unit-testable with a fake.
35
48
  */
36
49
  export async function scrollToFind(session, q, opts = {}) {
37
50
  const max = opts.maxScrolls ?? SCROLL_FIND_DEFAULTS.MAX_SCROLLS;
38
51
  const first = await queryFirst(session, q);
39
52
  if (first !== undefined)
40
53
  return { found: true, element: first, scrolls: 0, exhausted: false };
41
- // Bisection: if the caller knows the target index and list size, jump to the estimated offset
42
- // in one scroll command rather than stepping a viewport at a time. Then refine linearly.
54
+ const baseArgs = () => q.container !== undefined ? { ref: q.container } : {};
43
55
  let scrolls = 0;
56
+ let last = {};
57
+ // Bisection: if the caller knows the target index and list size, jump to the estimated offset in
58
+ // one scroll command rather than stepping a viewport at a time. The estimate can land on either
59
+ // side of the target, so refinement below still searches both directions from wherever this put us.
44
60
  if (q.targetIndex !== undefined && q.totalCount !== undefined && q.totalCount > 1) {
45
61
  const fraction = Math.min(1, Math.max(0, q.targetIndex / q.totalCount));
46
- const sr = await session.command(ReticleCommand.SCROLL, {
47
- ...(q.container !== undefined ? { ref: q.container } : {}),
48
- fraction,
49
- });
62
+ const sr = await session.command(ReticleCommand.SCROLL, { ...baseArgs(), fraction });
50
63
  scrolls += 1;
64
+ last = asRecord(sr.result);
51
65
  const hit = await queryFirst(session, q);
52
66
  if (hit !== undefined)
53
67
  return { found: true, element: hit, scrolls, exhausted: false };
54
- // Fall through to linear refinement from current position (already near the target).
68
+ }
69
+ let downwardSpent = false;
70
+ let everScrolled = false;
71
+ // The downward pass. Spending its whole budget without reaching the end means rows may remain
72
+ // further down, so that answer is `exhausted:false` and the upward pass never runs.
73
+ for (let i = 0; i < max && !downwardSpent; i += 1) {
74
+ const sr = await session.command(ReticleCommand.SCROLL, baseArgs());
75
+ scrolls += 1;
55
76
  const data = asRecord(sr.result);
56
- if (true === data['atEnd'] || false === data['scrolled']) {
57
- return { found: false, scrolls, exhausted: true, ...exhaustNote(q, data) };
58
- }
77
+ last = data;
78
+ if (true === data['scrolled'])
79
+ everScrolled = true;
80
+ const hit = await queryFirst(session, q);
81
+ if (hit !== undefined)
82
+ return { found: true, element: hit, scrolls, exhausted: false };
83
+ // Reached the bottom, or the container would not move downward — turn around before giving up.
84
+ if (true === data['atEnd'] || true !== data['scrolled'])
85
+ downwardSpent = true;
86
+ }
87
+ // If the downward pass spent its budget it never saw the end, so there is nothing to turn around
88
+ // for; report honestly rather than doubling a call whose caller asked for `max` steps.
89
+ if (!downwardSpent) {
90
+ return { found: false, scrolls, exhausted: false };
59
91
  }
92
+ // The upward pass gets the caller's budget again, not what is left of it: a list at the bottom of
93
+ // 14k px needs ~90 scrolls to reach its end and ~90 more to get back, so sharing one budget would
94
+ // strand the search halfway and answer `exhausted:false` about a list both of whose ends are known.
60
95
  for (let i = 0; i < max; i += 1) {
61
- const sr = await session.command(ReticleCommand.SCROLL, q.container !== undefined ? { ref: q.container } : {});
96
+ const sr = await session.command(ReticleCommand.SCROLL, { ...baseArgs(), dy: -upStepPx(last) });
62
97
  scrolls += 1;
63
98
  const data = asRecord(sr.result);
99
+ last = data;
100
+ if (true === data['scrolled'])
101
+ everScrolled = true;
64
102
  const hit = await queryFirst(session, q);
65
103
  if (hit !== undefined)
66
104
  return { found: true, element: hit, scrolls, exhausted: false };
67
- // Reached the bottom or the container would not move — no more rows to reveal.
68
- if (true === data['atEnd'] || false === data['scrolled']) {
69
- return { found: false, scrolls, exhausted: true, ...exhaustNote(q, data) };
105
+ // The top refusing to move means both directions are spent.
106
+ if (true !== data['scrolled']) {
107
+ return { found: false, scrolls, exhausted: true, ...exhaustNote(q, data, !everScrolled) };
70
108
  }
71
109
  }
72
- return { found: false, scrolls, exhausted: false }; // spent the budget; more may lie further down
110
+ return { found: false, scrolls, exhausted: false }; // spent the upward budget; more may lie above
73
111
  }
@@ -0,0 +1,46 @@
1
+ import type { ToolDeps } from '../tools/tool-kit.js';
2
+ /**
3
+ * The ledger id a piece of inline prose is declared under.
4
+ *
5
+ * Derived from the statement rather than minted, so the same sentence declared on five verdicts is
6
+ * one row rather than five, and a DIFFERENT sentence is a different row rather than a false
7
+ * amendment of the first. The slug is there because the id is what an agent later types to reference
8
+ * the intent, and a bare digest is unreadable; the digest is there because the slug is truncated and
9
+ * two long statements sharing a prefix must not share a row.
10
+ */
11
+ export declare function inlineIntentId(statement: string): string;
12
+ /** What `provenBy` records: which tool drew the verdict, and when. */
13
+ export declare function inlineVerdictId(tool: string, at: number): string;
14
+ /**
15
+ * Put an inline intent in the ledger and attach the predicate about to be evaluated. Returns the id
16
+ * the verdict may later discharge, or undefined when there was no intent to record.
17
+ *
18
+ * `intent` carries EITHER prose OR the id of an intent already in the ledger. One field rather than
19
+ * two because the surface is the scarce thing here and the two cases are told apart by the ledger
20
+ * itself: a string that names an existing row is a reference, anything else is prose. A statement
21
+ * that happens to equal an existing id is the same intent said twice, so pointing at the row is the
22
+ * right answer there too, not a collision.
23
+ *
24
+ * A referenced intent is never re-declared, which would overwrite the agent's own words with an id,
25
+ * and its binding is left alone when it already has one — a predicate bound deliberately through
26
+ * `reticle_intent { action: "bind" }` is a stronger statement than whatever this one call asserts,
27
+ * and quietly replacing it is exactly the narrowing the ledger exists to make visible.
28
+ *
29
+ * Best-effort by construction: a ledger that cannot be written is a small problem, and a verdict
30
+ * that fails to return because of one is a large problem.
31
+ */
32
+ export declare function linkInlineIntent(deps: ToolDeps, sessionId: string | undefined, intent: string | undefined,
33
+ /** The predicate this verdict will evaluate, or undefined when it proves nothing (a bare settle). */
34
+ binding: unknown): Promise<string | undefined>;
35
+ /**
36
+ * Record that this verdict proved the intent. Call only for a verdict that actually proved something.
37
+ *
38
+ * The ledger's own rule does the rest: `dischargeIntent` refuses an intent with no binding, so an
39
+ * intent declared inline on a wait that asserted no consequence stays open and honest instead of
40
+ * collecting a proof nothing earned.
41
+ */
42
+ export declare function dischargeInlineIntent(deps: ToolDeps, sessionId: string | undefined, id: string | undefined, proof: {
43
+ verdictId: string;
44
+ grade: string;
45
+ at: number;
46
+ }): Promise<void>;
@@ -0,0 +1,103 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { IntentStore } from './intent-store.js';
3
+ import { sessionRoot } from '../project/session-root.js';
4
+ /**
5
+ * Intent declared INLINE, on the tool that is already drawing the verdict.
6
+ *
7
+ * `reticle_intent` is on the extended surface, so declaring intent costs an agent a discovery, a
8
+ * decision and a round trip before it has done anything — three places the capture silently fails,
9
+ * and it does. `reticle_act_and_wait` and `reticle_assert` are in every agent's tool list already,
10
+ * so one optional argument there makes the declaration free and discoverable by construction.
11
+ *
12
+ * This is a shortcut into the EXISTING ledger, never a second one. It writes `.reticle/intent.json`
13
+ * through `IntentStore` exactly as `reticle_intent { action: "declare" }` does, so a reviewer reads
14
+ * one file in one vocabulary and cannot tell from the row which door the intent came in by. The
15
+ * pattern is `flows/flow-intent.ts`, which does the same job for a saved flow.
16
+ */
17
+ /** Namespaced like `flow:`, so an inline id never collides with one an agent chose by hand. */
18
+ const INLINE_INTENT_ID_PREFIX = 'inline:';
19
+ const ID_SEPARATOR = ':';
20
+ const SLUG_SEPARATOR = '-';
21
+ const SLUG_MAX = 48;
22
+ const HASH_LENGTH = 8;
23
+ const HASH_ALGORITHM = 'sha1';
24
+ const NON_SLUG = /[^a-z0-9]+/g;
25
+ const SLUG_EDGES = /^-+|-+$/g;
26
+ /**
27
+ * The ledger id a piece of inline prose is declared under.
28
+ *
29
+ * Derived from the statement rather than minted, so the same sentence declared on five verdicts is
30
+ * one row rather than five, and a DIFFERENT sentence is a different row rather than a false
31
+ * amendment of the first. The slug is there because the id is what an agent later types to reference
32
+ * the intent, and a bare digest is unreadable; the digest is there because the slug is truncated and
33
+ * two long statements sharing a prefix must not share a row.
34
+ */
35
+ export function inlineIntentId(statement) {
36
+ const slug = statement
37
+ .toLowerCase()
38
+ .replace(NON_SLUG, SLUG_SEPARATOR)
39
+ .replace(SLUG_EDGES, '')
40
+ .slice(0, SLUG_MAX)
41
+ .replace(SLUG_EDGES, '');
42
+ const digest = createHash(HASH_ALGORITHM).update(statement).digest('hex').slice(0, HASH_LENGTH);
43
+ return `${INLINE_INTENT_ID_PREFIX}${slug}${SLUG_SEPARATOR}${digest}`;
44
+ }
45
+ /** What `provenBy` records: which tool drew the verdict, and when. */
46
+ export function inlineVerdictId(tool, at) {
47
+ return `${tool}${ID_SEPARATOR}${String(at)}`;
48
+ }
49
+ /**
50
+ * Put an inline intent in the ledger and attach the predicate about to be evaluated. Returns the id
51
+ * the verdict may later discharge, or undefined when there was no intent to record.
52
+ *
53
+ * `intent` carries EITHER prose OR the id of an intent already in the ledger. One field rather than
54
+ * two because the surface is the scarce thing here and the two cases are told apart by the ledger
55
+ * itself: a string that names an existing row is a reference, anything else is prose. A statement
56
+ * that happens to equal an existing id is the same intent said twice, so pointing at the row is the
57
+ * right answer there too, not a collision.
58
+ *
59
+ * A referenced intent is never re-declared, which would overwrite the agent's own words with an id,
60
+ * and its binding is left alone when it already has one — a predicate bound deliberately through
61
+ * `reticle_intent { action: "bind" }` is a stronger statement than whatever this one call asserts,
62
+ * and quietly replacing it is exactly the narrowing the ledger exists to make visible.
63
+ *
64
+ * Best-effort by construction: a ledger that cannot be written is a small problem, and a verdict
65
+ * that fails to return because of one is a large problem.
66
+ */
67
+ export async function linkInlineIntent(deps, sessionId, intent,
68
+ /** The predicate this verdict will evaluate, or undefined when it proves nothing (a bare settle). */
69
+ binding) {
70
+ if (intent === undefined || 0 === intent.trim().length)
71
+ return undefined;
72
+ try {
73
+ const store = new IntentStore(deps.fs, sessionRoot(deps, sessionId), { now: deps.now });
74
+ const existing = (await store.read()).find((row) => row.id === intent);
75
+ const id = existing?.id ?? inlineIntentId(intent);
76
+ if (existing === undefined)
77
+ await store.declare([{ id, statement: intent }]);
78
+ if (binding !== undefined && existing?.binding === undefined)
79
+ await store.bind(id, binding);
80
+ return id;
81
+ }
82
+ catch {
83
+ return undefined;
84
+ }
85
+ }
86
+ /**
87
+ * Record that this verdict proved the intent. Call only for a verdict that actually proved something.
88
+ *
89
+ * The ledger's own rule does the rest: `dischargeIntent` refuses an intent with no binding, so an
90
+ * intent declared inline on a wait that asserted no consequence stays open and honest instead of
91
+ * collecting a proof nothing earned.
92
+ */
93
+ export async function dischargeInlineIntent(deps, sessionId, id, proof) {
94
+ if (id === undefined)
95
+ return;
96
+ try {
97
+ const store = new IntentStore(deps.fs, sessionRoot(deps, sessionId), { now: deps.now });
98
+ await store.discharge(id, proof);
99
+ }
100
+ catch {
101
+ // The verdict already stood. The proof is simply not recorded — see dischargeFlowIntent.
102
+ }
103
+ }
@@ -0,0 +1,37 @@
1
+ import { type Intent, type IntentSurface } from '@reticlehq/core';
2
+ import type { FileSystemPort } from '../project/fs-port.js';
3
+ export interface Clock {
4
+ now: () => number;
5
+ }
6
+ export declare class IntentStore {
7
+ #private;
8
+ constructor(fs: FileSystemPort, root: string, clock: Clock);
9
+ /** Every intent, in declaration order. */
10
+ read(): Promise<Intent[]>;
11
+ /** Everything not yet proved — what an agent asking "am I done?" still owes. */
12
+ open(): Promise<Intent[]>;
13
+ /**
14
+ * Declare one or more intents.
15
+ *
16
+ * Batched because the marginal cost of the whole mechanism has to stay at one call per feature; an
17
+ * agent that must make five calls to declare five things will make none.
18
+ */
19
+ declare(entries: readonly {
20
+ id: string;
21
+ statement: string;
22
+ surface?: IntentSurface;
23
+ }[]): Promise<Intent[]>;
24
+ /** Attach the predicate that would prove an intent. False when the id names nothing. */
25
+ bind(id: string, binding: unknown): Promise<boolean>;
26
+ /**
27
+ * Record that a verdict proved an intent.
28
+ *
29
+ * False rather than a throw on an unknown or unbound id: discharge runs off the back of a verdict,
30
+ * and must never be the reason one fails to return.
31
+ */
32
+ discharge(id: string, proof: {
33
+ verdictId: string;
34
+ grade: string;
35
+ at: number;
36
+ }): Promise<boolean>;
37
+ }