@reticlehq/server 3.9.0 → 3.10.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 (350) hide show
  1. package/SKILL.md +13 -12
  2. package/dist/command/cli/adhoc-suite.js +2 -0
  3. package/dist/command/cli/adhoc-verdict.d.ts +2 -43
  4. package/dist/command/cli/adhoc-verdict.js +50 -187
  5. package/dist/command/cli/answers/mcp-connection-hint.d.ts +2 -0
  6. package/dist/command/cli/answers/mcp-connection-hint.js +6 -0
  7. package/dist/command/cli/cli-doctor.js +1 -1
  8. package/dist/command/cli/cli-flow-commands.js +2 -9
  9. package/dist/command/cli/cli-kill.js +1 -1
  10. package/dist/command/cli/cli-parse-compare.d.ts +22 -0
  11. package/dist/command/cli/cli-parse-compare.js +75 -0
  12. package/dist/command/cli/cli-parse-grade.d.ts +36 -0
  13. package/dist/command/cli/cli-parse-grade.js +75 -0
  14. package/dist/command/cli/cli-parse-grammar.d.ts +2 -0
  15. package/dist/command/cli/cli-parse-grammar.js +18 -20
  16. package/dist/command/cli/cli-parse-verify.d.ts +10 -0
  17. package/dist/command/cli/cli-parse-verify.js +63 -14
  18. package/dist/command/cli/cli-parse.d.ts +17 -15
  19. package/dist/command/cli/cli-parse.js +44 -27
  20. package/dist/command/cli/cli-update-commands.d.ts +1 -1
  21. package/dist/command/cli/cli-update-commands.js +29 -20
  22. package/dist/command/cli/cli-usage.js +58 -29
  23. package/dist/command/cli/cli-verify.d.ts +11 -2
  24. package/dist/command/cli/cli-verify.js +27 -8
  25. package/dist/command/cli/cloud-cli.js +2 -119
  26. package/dist/command/cli/daemon-link/daemon-client.d.ts +45 -0
  27. package/dist/command/cli/daemon-link/daemon-client.js +205 -0
  28. package/dist/command/cli/doctor/doctor-daemon-line.js +6 -3
  29. package/dist/command/cli/doctor-repairs.js +1 -1
  30. package/dist/command/cli/ensure-daemon.d.ts +26 -0
  31. package/dist/command/cli/ensure-daemon.js +57 -0
  32. package/dist/command/cli/finished-run/grade-command.d.ts +23 -0
  33. package/dist/command/cli/finished-run/grade-command.js +180 -0
  34. package/dist/command/cli/lifecycle/daemon-lifecycle.d.ts +1 -1
  35. package/dist/command/cli/lifecycle/daemon-lifecycle.js +1 -1
  36. package/dist/command/cli/ports/port-holder.d.ts +9 -0
  37. package/dist/command/cli/ports/port-holder.js +27 -3
  38. package/dist/command/cli/status/status-lines.d.ts +1 -0
  39. package/dist/command/cli/status/status-lines.js +22 -0
  40. package/dist/command/cli/status-fields.d.ts +5 -1
  41. package/dist/command/cli/status-fields.js +7 -3
  42. package/dist/command/cli/two-builds/compare-command.d.ts +48 -0
  43. package/dist/command/cli/two-builds/compare-command.js +314 -0
  44. package/dist/command/cli/verify-against.d.ts +61 -0
  45. package/dist/command/cli/verify-against.js +187 -0
  46. package/dist/command/cli/verify-no-session.d.ts +2 -0
  47. package/dist/command/cli/verify-no-session.js +13 -0
  48. package/dist/command/cli/verify-results.d.ts +35 -2
  49. package/dist/command/cli/verify-results.js +76 -10
  50. package/dist/command/cli.js +46 -41
  51. package/dist/command/daemon/daemon-resolve.d.ts +8 -1
  52. package/dist/command/daemon/daemon-resolve.js +27 -1
  53. package/dist/command/daemon/daemon.d.ts +12 -1
  54. package/dist/command/daemon/daemon.js +35 -4
  55. package/dist/command/daemon/lifetime/death-cause.js +21 -2
  56. package/dist/command/daemon/lifetime/heartbeat.js +7 -2
  57. package/dist/command/daemon/lifetime/startup-failure.d.ts +29 -0
  58. package/dist/command/daemon/lifetime/startup-failure.js +62 -0
  59. package/dist/command/setup/bringup/dev-server-files.d.ts +9 -0
  60. package/dist/command/setup/bringup/dev-server-files.js +17 -0
  61. package/dist/command/setup/bringup/static-page-server.d.ts +14 -2
  62. package/dist/command/setup/bringup/static-page-server.js +43 -9
  63. package/dist/command/setup/init/init-runtime.js +6 -0
  64. package/dist/command/setup/node-effects.d.ts +3 -2
  65. package/dist/command/setup/node-effects.js +9 -3
  66. package/dist/command/setup/remaining-steps.d.ts +8 -0
  67. package/dist/command/setup/remaining-steps.js +10 -0
  68. package/dist/command/setup/run-setup.d.ts +2 -0
  69. package/dist/command/setup/run-setup.js +9 -1
  70. package/dist/command/setup/setup-command.js +6 -3
  71. package/dist/command/update/updater.js +6 -0
  72. package/dist/command/version/version-skew.js +1 -1
  73. package/dist/features/compare/action-plan.d.ts +260 -0
  74. package/dist/features/compare/action-plan.js +543 -0
  75. package/dist/features/compare/behaviour-compare.d.ts +24 -0
  76. package/dist/features/compare/behaviour-compare.js +85 -0
  77. package/dist/features/compare/behaviour-diff.d.ts +97 -0
  78. package/dist/features/compare/behaviour-diff.js +202 -0
  79. package/dist/features/compare/behaviour-report.d.ts +40 -0
  80. package/dist/features/compare/behaviour-report.js +128 -0
  81. package/dist/features/compare/build-readings.d.ts +106 -0
  82. package/dist/features/compare/build-readings.js +250 -0
  83. package/dist/features/compare/compare-diff.d.ts +62 -0
  84. package/dist/features/compare/compare-diff.js +177 -0
  85. package/dist/features/compare/compare-report-html.d.ts +6 -0
  86. package/dist/features/compare/compare-report-html.js +102 -0
  87. package/dist/features/compare/compare-report.d.ts +15 -0
  88. package/dist/features/compare/compare-report.js +73 -0
  89. package/dist/features/compare/compare-run.d.ts +60 -0
  90. package/dist/features/compare/compare-run.js +123 -0
  91. package/dist/features/compare/outside/effect-lines.d.ts +26 -0
  92. package/dist/features/compare/outside/effect-lines.js +103 -0
  93. package/dist/features/compare/outside/state-sources.d.ts +76 -0
  94. package/dist/features/compare/outside/state-sources.js +164 -0
  95. package/dist/features/compare/outside/value-dictionary.d.ts +51 -0
  96. package/dist/features/compare/outside/value-dictionary.js +227 -0
  97. package/dist/features/compare/visible-content.d.ts +20 -0
  98. package/dist/features/compare/visible-content.js +125 -0
  99. package/dist/features/crawl/crawl-empty.js +2 -1
  100. package/dist/features/crawl/crawl-exhaustive.d.ts +55 -0
  101. package/dist/features/crawl/crawl-exhaustive.js +138 -0
  102. package/dist/features/crawl/crawl-tools.d.ts +9 -2
  103. package/dist/features/crawl/crawl-tools.js +28 -12
  104. package/dist/features/crawl/crawl.d.ts +8 -9
  105. package/dist/features/crawl/crawl.js +61 -65
  106. package/dist/features/exhaust/app-coverage.d.ts +5 -1
  107. package/dist/features/exhaust/app-coverage.js +3 -1
  108. package/dist/features/exhaust/code-coverage.js +8 -4
  109. package/dist/features/exhaust/edge-cases.d.ts +133 -0
  110. package/dist/features/exhaust/edge-cases.js +310 -0
  111. package/dist/features/exhaust/edges/edge-drive.d.ts +42 -0
  112. package/dist/features/exhaust/edges/edge-drive.js +364 -0
  113. package/dist/features/exhaust/edges/edge-run.d.ts +59 -0
  114. package/dist/features/exhaust/edges/edge-run.js +169 -0
  115. package/dist/features/exhaust/explorer.d.ts +87 -14
  116. package/dist/features/exhaust/explorer.js +159 -25
  117. package/dist/features/exhaust/failure-branches.d.ts +5 -0
  118. package/dist/features/exhaust/failure-branches.js +29 -11
  119. package/dist/features/exhaust/invariants.d.ts +112 -0
  120. package/dist/features/exhaust/invariants.js +188 -0
  121. package/dist/features/exhaust/ledger.d.ts +68 -0
  122. package/dist/features/exhaust/ledger.js +121 -13
  123. package/dist/features/exhaust/session-fold.d.ts +10 -0
  124. package/dist/features/exhaust/session-fold.js +46 -3
  125. package/dist/features/exhaust/session-port.d.ts +3 -1
  126. package/dist/features/exhaust/session-port.js +97 -28
  127. package/dist/features/grade/grade-answer.d.ts +99 -0
  128. package/dist/features/grade/grade-answer.js +112 -0
  129. package/dist/features/grade/grade-noise.d.ts +27 -0
  130. package/dist/features/grade/grade-noise.js +55 -0
  131. package/dist/features/grade/grade-renumber.d.ts +12 -0
  132. package/dist/features/grade/grade-renumber.js +86 -0
  133. package/dist/features/grade/grade-state.d.ts +50 -0
  134. package/dist/features/grade/grade-state.js +93 -0
  135. package/dist/features/grade/grade-suggest.d.ts +33 -0
  136. package/dist/features/grade/grade-suggest.js +98 -0
  137. package/dist/features/visual/visual-tools.js +66 -19
  138. package/dist/index.js +7 -4
  139. package/dist/judgement/capsule/causal-summary.js +9 -2
  140. package/dist/judgement/runs/artifact/replay-mapping.d.ts +13 -1
  141. package/dist/judgement/runs/artifact/replay-mapping.js +1 -1
  142. package/dist/judgement/runs/reticle-runner.js +16 -2
  143. package/dist/judgement/runs/runner-port.d.ts +2 -0
  144. package/dist/judgement/runs/runner-port.js +23 -5
  145. package/dist/language/flows/affected-tools.d.ts +1 -1
  146. package/dist/language/flows/affected-tools.js +49 -30
  147. package/dist/language/flows/change/git-changed.d.ts +2 -1
  148. package/dist/language/flows/change/git-changed.js +19 -2
  149. package/dist/language/flows/fields/flow-secret-field.d.ts +1 -13
  150. package/dist/language/flows/fields/flow-secret-field.js +77 -21
  151. package/dist/language/flows/flow-anchor.d.ts +60 -2
  152. package/dist/language/flows/flow-anchor.js +125 -20
  153. package/dist/language/flows/flow-classify.d.ts +20 -0
  154. package/dist/language/flows/flow-classify.js +90 -12
  155. package/dist/language/flows/flow-cross-step.d.ts +7 -1
  156. package/dist/language/flows/flow-cross-step.js +25 -1
  157. package/dist/language/flows/flow-journey.d.ts +38 -4
  158. package/dist/language/flows/flow-journey.js +138 -8
  159. package/dist/language/flows/flow-replay-run.d.ts +0 -16
  160. package/dist/language/flows/flow-replay-run.js +76 -14
  161. package/dist/language/flows/flow-replay-types.d.ts +4 -2
  162. package/dist/language/flows/flow-replay.d.ts +19 -1
  163. package/dist/language/flows/flow-replay.js +169 -52
  164. package/dist/language/flows/flow-replayable.js +7 -1
  165. package/dist/language/flows/flow-start-path.d.ts +34 -5
  166. package/dist/language/flows/flow-start-path.js +202 -15
  167. package/dist/language/flows/flow-step-runners.d.ts +14 -13
  168. package/dist/language/flows/flow-step-runners.js +184 -109
  169. package/dist/language/flows/flow-store-for-session.d.ts +26 -25
  170. package/dist/language/flows/flow-store-for-session.js +44 -0
  171. package/dist/language/flows/flow-tools.js +46 -33
  172. package/dist/language/flows/flows.js +176 -76
  173. package/dist/language/flows/recording/tape/recordings.d.ts +19 -3
  174. package/dist/language/flows/recording/tape/recordings.js +71 -5
  175. package/dist/language/flows/replay.d.ts +19 -5
  176. package/dist/language/flows/replay.js +99 -7
  177. package/dist/language/flows/steps/page-steps.d.ts +68 -0
  178. package/dist/language/flows/steps/page-steps.js +198 -0
  179. package/dist/language/flows/suite/sequential-run.d.ts +68 -0
  180. package/dist/language/flows/suite/sequential-run.js +96 -0
  181. package/dist/language/flows/verify-change-tools.js +26 -15
  182. package/dist/memory/cloud/background-sync.d.ts +13 -0
  183. package/dist/memory/cloud/background-sync.js +24 -3
  184. package/dist/memory/cloud/sync-daemon.js +26 -27
  185. package/dist/memory/journal/attach-journal.js +2 -1
  186. package/dist/memory/journal/drive-flow.d.ts +7 -0
  187. package/dist/memory/journal/drive-flow.js +44 -13
  188. package/dist/memory/journal/journal-recorder.d.ts +4 -2
  189. package/dist/memory/journal/session-journal.d.ts +7 -0
  190. package/dist/memory/journal/session-journal.js +58 -10
  191. package/dist/memory/project/root-claims.d.ts +9 -0
  192. package/dist/memory/project/root-claims.js +9 -4
  193. package/dist/portal/input/download-capture.d.ts +7 -0
  194. package/dist/portal/input/download-capture.js +92 -0
  195. package/dist/portal/input/network-detail.d.ts +6 -0
  196. package/dist/portal/input/network-detail.js +12 -1
  197. package/dist/portal/input/network-mock-tools.js +49 -13
  198. package/dist/portal/input/network-mock.d.ts +8 -0
  199. package/dist/portal/input/network-mock.js +28 -9
  200. package/dist/portal/input/page-capture.d.ts +24 -0
  201. package/dist/portal/input/page-capture.js +33 -0
  202. package/dist/portal/input/real-input.d.ts +22 -20
  203. package/dist/portal/input/real-input.js +41 -31
  204. package/dist/portal/input/viewport-tools.d.ts +8 -0
  205. package/dist/portal/input/viewport-tools.js +75 -18
  206. package/dist/portal/pool/browser-pool.d.ts +11 -29
  207. package/dist/portal/pool/browser-pool.js +35 -17
  208. package/dist/portal/pool/context-permissions.d.ts +17 -3
  209. package/dist/portal/pool/context-permissions.js +36 -5
  210. package/dist/portal/pool/determinism.d.ts +52 -0
  211. package/dist/portal/pool/determinism.js +259 -0
  212. package/dist/portal/pool/lease-emulation.d.ts +34 -1
  213. package/dist/portal/pool/lease-emulation.js +18 -0
  214. package/dist/portal/pool/lease-marker-stamp.d.ts +6 -0
  215. package/dist/portal/pool/lease-marker-stamp.js +15 -1
  216. package/dist/portal/pool/lease-page-error.d.ts +44 -17
  217. package/dist/portal/pool/lease-page-error.js +147 -5
  218. package/dist/portal/pool/page-error-journal.d.ts +7 -1
  219. package/dist/portal/pool/page-error-journal.js +27 -0
  220. package/dist/portal/pool/playwright-launcher.js +51 -8
  221. package/dist/portal/pool/pool-contract.d.ts +27 -1
  222. package/dist/portal/pool/pool-lease.d.ts +36 -1
  223. package/dist/portal/pool/zero-install.d.ts +8 -0
  224. package/dist/portal/pool/zero-install.js +23 -1
  225. package/dist/portal/session/dev-server/dev-command.d.ts +7 -6
  226. package/dist/portal/session/dev-server/dev-command.js +61 -15
  227. package/dist/portal/session/dev-server/dev-server-probe.d.ts +7 -0
  228. package/dist/portal/session/dev-server/dev-server-probe.js +9 -0
  229. package/dist/portal/session/dev-server/project-ports.d.ts +21 -0
  230. package/dist/portal/session/dev-server/project-ports.js +62 -0
  231. package/dist/portal/session/facts/departed-network.d.ts +66 -0
  232. package/dist/portal/session/facts/departed-network.js +142 -0
  233. package/dist/portal/session/no-session-diagnosis.d.ts +9 -0
  234. package/dist/portal/session/no-session-diagnosis.js +67 -25
  235. package/dist/portal/session/no-session-next-action.d.ts +2 -0
  236. package/dist/portal/session/no-session-next-action.js +12 -3
  237. package/dist/portal/session/no-session-watch.d.ts +6 -0
  238. package/dist/portal/session/no-session-watch.js +38 -3
  239. package/dist/portal/session/serving-directory.d.ts +10 -0
  240. package/dist/portal/session/serving-directory.js +39 -8
  241. package/dist/portal/session/session-manager.d.ts +10 -0
  242. package/dist/portal/session/session-manager.js +174 -63
  243. package/dist/start-wiring.d.ts +2 -1
  244. package/dist/start-wiring.js +2 -1
  245. package/dist/surface/mcp/mcp.js +3 -2
  246. package/dist/surface/mcp/shared-params.d.ts +2 -0
  247. package/dist/surface/mcp/shared-params.js +31 -0
  248. package/dist/surface/tools/act/act-observation.d.ts +40 -2
  249. package/dist/surface/tools/act/act-observation.js +118 -12
  250. package/dist/surface/tools/act/act-preflight.js +1 -1
  251. package/dist/surface/tools/act/act-sequence-retry.d.ts +23 -2
  252. package/dist/surface/tools/act/act-sequence-retry.js +61 -5
  253. package/dist/surface/tools/act/after-match.d.ts +4 -1
  254. package/dist/surface/tools/act/after-match.js +8 -1
  255. package/dist/surface/tools/act/already-true.js +1 -1
  256. package/dist/surface/tools/act/navigation/reload-result.d.ts +12 -1
  257. package/dist/surface/tools/act/navigation/reload-result.js +25 -2
  258. package/dist/surface/tools/act/resolve-target.d.ts +15 -1
  259. package/dist/surface/tools/act/resolve-target.js +24 -1
  260. package/dist/surface/tools/act/sequence-grade.d.ts +12 -0
  261. package/dist/surface/tools/act/sequence-grade.js +37 -26
  262. package/dist/surface/tools/act/settle-in-flight.js +27 -15
  263. package/dist/surface/tools/act-command.d.ts +23 -0
  264. package/dist/surface/tools/act-command.js +50 -0
  265. package/dist/surface/tools/act-sequence-tool.js +78 -50
  266. package/dist/surface/tools/act-tools.d.ts +1 -13
  267. package/dist/surface/tools/act-tools.js +74 -86
  268. package/dist/surface/tools/agent/build-staleness.d.ts +1 -1
  269. package/dist/surface/tools/agent/build-staleness.js +1 -1
  270. package/dist/surface/tools/assert/assert-verdict.js +16 -37
  271. package/dist/surface/tools/browser-tools.js +12 -6
  272. package/dist/surface/tools/compare-tools.d.ts +11 -0
  273. package/dist/surface/tools/compare-tools.js +94 -0
  274. package/dist/surface/tools/coverage-tools.js +1 -1
  275. package/dist/surface/tools/dynamic-tools.js +9 -2
  276. package/dist/surface/tools/error-recovery.d.ts +12 -1
  277. package/dist/surface/tools/error-recovery.js +91 -21
  278. package/dist/surface/tools/explore-tools.js +1 -1
  279. package/dist/surface/tools/invoke-tool.js +3 -2
  280. package/dist/surface/tools/lease-coverage-tools.d.ts +49 -0
  281. package/dist/surface/tools/lease-coverage-tools.js +128 -0
  282. package/dist/surface/tools/lease-hint.d.ts +35 -1
  283. package/dist/surface/tools/lease-hint.js +42 -0
  284. package/dist/surface/tools/lease-readiness.d.ts +26 -0
  285. package/dist/surface/tools/lease-readiness.js +79 -0
  286. package/dist/surface/tools/lease-seed.d.ts +14 -1
  287. package/dist/surface/tools/lease-seed.js +35 -3
  288. package/dist/surface/tools/lease-tools.d.ts +3 -41
  289. package/dist/surface/tools/lease-tools.js +62 -110
  290. package/dist/surface/tools/lease-url-identity.d.ts +46 -0
  291. package/dist/surface/tools/lease-url-identity.js +97 -0
  292. package/dist/surface/tools/merge-tools.js +99 -2
  293. package/dist/surface/tools/merged-name-redirect.js +5 -1
  294. package/dist/surface/tools/observe-tools.js +15 -9
  295. package/dist/surface/tools/read/console-pre-attach.d.ts +19 -0
  296. package/dist/surface/tools/read/console-pre-attach.js +23 -0
  297. package/dist/surface/tools/read-tools.js +33 -30
  298. package/dist/surface/tools/real-input-attempt.d.ts +0 -8
  299. package/dist/surface/tools/real-input-attempt.js +143 -28
  300. package/dist/surface/tools/session-ports.d.ts +14 -0
  301. package/dist/surface/tools/session-ports.js +53 -0
  302. package/dist/surface/tools/tool-kit.d.ts +6 -0
  303. package/dist/surface/tools/tool-kit.js +2 -3
  304. package/dist/surface/tools/tool-surface.js +0 -1
  305. package/dist/surface/tools/tools.js +28 -5
  306. package/dist/surface/tools/type-spelling.d.ts +13 -0
  307. package/dist/surface/tools/type-spelling.js +36 -0
  308. package/dist/telemetry/agent-client.d.ts +6 -0
  309. package/dist/telemetry/agent-client.js +10 -0
  310. package/dist/telemetry/argument-shape.js +1 -0
  311. package/dist/telemetry/tool-refused.d.ts +6 -3
  312. package/dist/telemetry/tool-refused.js +8 -3
  313. package/docs/actions.mdx +3 -3
  314. package/docs/agent-cheatsheet.md +1 -1
  315. package/docs/capabilities.mdx +61 -1
  316. package/docs/cli/affected.mdx +9 -7
  317. package/docs/cli/cloud.mdx +3 -3
  318. package/docs/cli/compare.mdx +94 -0
  319. package/docs/cli/doctor.mdx +1 -1
  320. package/docs/cli/grade.mdx +97 -0
  321. package/docs/cli/license.mdx +9 -7
  322. package/docs/cli/restart.mdx +7 -5
  323. package/docs/cli/rollback.mdx +5 -5
  324. package/docs/cli/stop.mdx +3 -3
  325. package/docs/cli/update.mdx +2 -2
  326. package/docs/cli/verify.mdx +23 -1
  327. package/docs/cli.mdx +33 -23
  328. package/docs/deploy-checks.md +1 -1
  329. package/docs/desktop.mdx +1 -1
  330. package/docs/enterprise.md +2 -2
  331. package/docs/environment.mdx +1 -1
  332. package/docs/flows.md +11 -1
  333. package/docs/frameworks.mdx +9 -9
  334. package/docs/getting-started.md +1 -1
  335. package/docs/install-manual.mdx +1 -1
  336. package/docs/license-activation.mdx +3 -3
  337. package/docs/packages/tauri.mdx +1 -1
  338. package/docs/packages.mdx +1 -1
  339. package/docs/predicates.mdx +1 -1
  340. package/docs/quickstart.mdx +68 -49
  341. package/docs/skill-setup.md +1 -1
  342. package/docs/skill.md +14 -13
  343. package/docs/telemetry.md +2 -2
  344. package/docs/tools/navigate.mdx +1 -1
  345. package/docs/tools/verify.mdx +4 -1
  346. package/docs/troubleshooting.mdx +1 -1
  347. package/docs/usage.md +17 -14
  348. package/package.json +13 -13
  349. package/dist/features/exhaust/exhaust-run.d.ts +0 -33
  350. package/dist/features/exhaust/exhaust-run.js +0 -82
@@ -0,0 +1,187 @@
1
+ /**
2
+ * `reticle verify <candidate-url> --against <reference-url>`: did a change alter what users see?
3
+ *
4
+ * It drives both running builds with the same actions (the saved flows, and the explorer on the
5
+ * reference) and reports each difference in what a person can read and use. The rules are in
6
+ * `@/features/compare`. This file connects them to the running daemon: each run is a leased tab,
7
+ * and each read or action is a tool call on that tab, the same calls an agent makes.
8
+ */
9
+ import { writeFile } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { ReticleDir, ReticleTool, SnapshotMode, isConsequenceDrift, selectFlows, } from '@reticlehq/core';
12
+ import { compareBuilds } from '../../features/compare/compare-run.js';
13
+ import { compareExit, compareJson, compareLines } from '../../features/compare/compare-report.js';
14
+ import { FlowStore } from '../../language/flows/flows.js';
15
+ import { createNodeFileSystem } from '../../memory/project/fs/fs-port.js';
16
+ import { callerArtifactRoot } from '../../memory/project/link-directory.js';
17
+ import { defaultPairingTokenDir, readOrCreatePairingTokenSync, } from '../../portal/bridge/pairing-token.js';
18
+ import { acquireLease, connectOverSse, endpointFor, payloadOf, refusalText, releaseLease, } from './daemon-link/daemon-client.js';
19
+ /** How long a navigation may take before the start page counts as not loaded. */
20
+ const NAVIGATE_WAIT_MS = 15_000;
21
+ /** One replayed flow step: a step can wait for its own consequence, so give it room. */
22
+ const REPLAY_STEP_CALL_MS = 120_000;
23
+ /** How long to wait after each action before the page is read, so a short timer can fire. */
24
+ export const AGAINST_WAIT_MS = 300;
25
+ /** The explorer's action budget on the reference. */
26
+ export const AGAINST_MAX_ACTIONS = 150;
27
+ /** The label of a replayed step that names no anchor. */
28
+ const UNNAMED_STEP = 'step';
29
+ const MSG_LEASE_FAILED = 'could not open a browser on';
30
+ const MSG_NO_DAEMON = 'could not reach the daemon on port';
31
+ const asText = (value) => 'string' === typeof value ? value : undefined;
32
+ /** A step that ran: it passed, or its action ran and only its declared consequence did not hold. */
33
+ function stepDone(step) {
34
+ if (step === undefined) {
35
+ return false;
36
+ }
37
+ if (true === step['ok']) {
38
+ return true;
39
+ }
40
+ const drift = step['drift'];
41
+ const reason = asText(drift?.reasonKind);
42
+ return reason !== undefined && isConsequenceDrift(reason);
43
+ }
44
+ /** One leased tab, as the compare driver. Every call goes through `reticle_run` on that tab. */
45
+ export function daemonDriver(caller, tab) {
46
+ let sessionId = tab.sessionId;
47
+ const run = async (tool, args, timeoutMs) => {
48
+ const result = await caller.call(ReticleTool.RUN, { tool, args: { ...args, sessionId } }, timeoutMs);
49
+ return { payload: payloadOf(result), refused: refusalText(result) !== undefined };
50
+ };
51
+ const snapshot = async (mode) => (await run(ReticleTool.SNAPSHOT, { mode })).payload;
52
+ return {
53
+ async reset() {
54
+ const { payload, refused } = await run(ReticleTool.NAVIGATE, { url: tab.url, timeout_ms: NAVIGATE_WAIT_MS }, NAVIGATE_WAIT_MS * 2);
55
+ const next = asText(payload['sessionId']);
56
+ if (next !== undefined) {
57
+ sessionId = next;
58
+ }
59
+ return !refused && true === payload['ok'] && false !== payload['confirmed'];
60
+ },
61
+ async look() {
62
+ const body = await snapshot(SnapshotMode.INTERACTIVE);
63
+ const route = asText(body['status']?.route);
64
+ return { tree: asText(body['tree']) ?? '', ...(route === undefined ? {} : { route }) };
65
+ },
66
+ async read() {
67
+ return asText((await snapshot(SnapshotMode.FULL))['tree']) ?? '';
68
+ },
69
+ async act(ref, action, value) {
70
+ const { payload, refused } = await run(ReticleTool.ACT, {
71
+ ref,
72
+ action,
73
+ args: value === undefined ? {} : { value },
74
+ });
75
+ return { ok: !refused && false !== payload['dispatched'], events: [] };
76
+ },
77
+ async replayStep(flow, index) {
78
+ const { payload } = await run(ReticleTool.FLOW_REPLAY, {
79
+ flowName: flow,
80
+ to: index + 1,
81
+ ...(0 === index ? {} : { at: index }),
82
+ ...(true === tab.confirmDangerous ? { confirmDangerous: true } : {}),
83
+ }, REPLAY_STEP_CALL_MS);
84
+ const steps = Array.isArray(payload['steps']) ? payload['steps'] : [];
85
+ const step = steps.map((s) => s).find((s) => index === s['step']);
86
+ return { done: stepDone(step), label: asText(step?.['anchor']) ?? UNNAMED_STEP };
87
+ },
88
+ async close() {
89
+ if (tab.closeLease) {
90
+ await releaseLease(caller, sessionId);
91
+ }
92
+ },
93
+ };
94
+ }
95
+ /** The saved flows a run replays, with `--select` applied, and how many steps each has. */
96
+ export async function flowsToReplay(fs, reticleRoot, select, now) {
97
+ const store = new FlowStore(fs, reticleRoot, { now });
98
+ const files = [];
99
+ for (const name of await store.list()) {
100
+ const loaded = await store.load(name);
101
+ if (loaded.ok) {
102
+ files.push(loaded.value);
103
+ }
104
+ }
105
+ const chosen = selectFlows(files, select === undefined ? {} : { labels: [...select] }).run;
106
+ return chosen.map((flow) => ({ name: flow.name, steps: flow.steps.length }));
107
+ }
108
+ /** Compare the two builds through the daemon on `port`. Returns the lines to print and the exit code. */
109
+ export async function runVerifyAgainst(options) {
110
+ const connect = options.connect ?? connectOverSse;
111
+ let caller;
112
+ try {
113
+ caller = await connect(endpointFor(options.port, options.token));
114
+ }
115
+ catch (error) {
116
+ const why = error instanceof Error ? error.message : String(error);
117
+ return { code: 1, lines: [`${MSG_NO_DAEMON} ${String(options.port)}: ${why}`] };
118
+ }
119
+ const open = async (url) => {
120
+ const lease = await acquireLease(caller, url, false, options.root);
121
+ if ('failed' in lease) {
122
+ throw new Error([`${MSG_LEASE_FAILED} ${url}`, ...lease.failed].join('\n'));
123
+ }
124
+ return daemonDriver(caller, {
125
+ url,
126
+ sessionId: lease.leased,
127
+ closeLease: true,
128
+ ...(true === options.confirmDangerous ? { confirmDangerous: true } : {}),
129
+ });
130
+ };
131
+ try {
132
+ const result = await compareBuilds({
133
+ reference: options.reference,
134
+ candidate: options.candidate,
135
+ open,
136
+ flows: options.flows,
137
+ maxActions: AGAINST_MAX_ACTIONS,
138
+ wait: () => options.sleep(AGAINST_WAIT_MS),
139
+ });
140
+ const urls = { reference: options.reference, candidate: options.candidate };
141
+ if (options.resultsJson !== undefined) {
142
+ const write = options.write ?? ((path, text) => writeFile(path, text, 'utf8'));
143
+ await write(options.resultsJson, `${JSON.stringify(compareJson(urls, result), null, 2)}\n`);
144
+ }
145
+ return { code: compareExit(result), lines: compareLines(urls, result) };
146
+ }
147
+ catch (error) {
148
+ return { code: 1, lines: [error instanceof Error ? error.message : String(error)] };
149
+ }
150
+ finally {
151
+ await caller.close().catch(() => undefined);
152
+ }
153
+ }
154
+ /**
155
+ * The command. It needs a daemon, as an agent does: `ensureDaemon` attaches to the one on the port,
156
+ * or starts one. Nothing here stops a daemon, so an agent's MCP link stays up.
157
+ */
158
+ export async function handleVerifyAgainst(parsed, ensureDaemon, out) {
159
+ try {
160
+ await ensureDaemon(parsed.port);
161
+ }
162
+ catch (error) {
163
+ out.fail(error instanceof Error ? error.message : String(error));
164
+ out.exit(1);
165
+ return;
166
+ }
167
+ const cwd = process.cwd();
168
+ const root = callerArtifactRoot(cwd);
169
+ const token = readOrCreatePairingTokenSync(defaultPairingTokenDir());
170
+ // The same `.reticle` the leased tabs replay from, so the list and the replay agree.
171
+ const flows = await flowsToReplay(createNodeFileSystem(), root ?? join(cwd, ReticleDir.ROOT), parsed.select, Date.now);
172
+ const result = await runVerifyAgainst({
173
+ port: parsed.port,
174
+ candidate: parsed.url,
175
+ reference: parsed.against,
176
+ flows,
177
+ ...(token === undefined || 0 === token.length ? {} : { token }),
178
+ ...(root === undefined ? {} : { root }),
179
+ ...(true === parsed.confirmDangerous ? { confirmDangerous: true } : {}),
180
+ ...(parsed.resultsJson === undefined ? {} : { resultsJson: parsed.resultsJson }),
181
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
182
+ });
183
+ for (const line of result.lines) {
184
+ out.line(line);
185
+ }
186
+ out.exit(result.code);
187
+ }
@@ -0,0 +1,2 @@
1
+ /** Why no session connected. A short timeout also says that the unit is milliseconds. */
2
+ export declare function noSessionMessage(timeoutMs: number): string;
@@ -0,0 +1,13 @@
1
+ const MSG_NO_SESSION = 'No app connected: Reticle drove the URL but no @reticlehq/browser session dialed back.\n' +
2
+ ' Make sure the SDK is in the build and reticle.connect() runs on the preview page' +
3
+ ' (for a non-localhost preview: allowNonLocalhost + a pairing token).';
4
+ /** A `--timeout` under this is likely a count of seconds, so the message names the unit. */
5
+ const SHORT_TIMEOUT_MS = 1000;
6
+ /** Why no session connected. A short timeout also says that the unit is milliseconds. */
7
+ export function noSessionMessage(timeoutMs) {
8
+ if (timeoutMs >= SHORT_TIMEOUT_MS) {
9
+ return MSG_NO_SESSION;
10
+ }
11
+ return (`${MSG_NO_SESSION}\n The wait was ${String(timeoutMs)} ms: the timeout is in milliseconds. ` +
12
+ 'Use --timeout 60000 to wait 60 seconds.');
13
+ }
@@ -7,7 +7,31 @@
7
7
  */
8
8
  import { Verified, type ReticleVerificationRun } from '@reticlehq/core';
9
9
  type JourneyVerdict = typeof Verified.YES | typeof Verified.NO | typeof Verified.UNKNOWN;
10
+ /**
11
+ * How many steps passed, failed, or were never judged. `unknown` is a step the replay never
12
+ * reached or could not decide; it stays in the total, so an undecided step never raises a score.
13
+ */
14
+ export interface StepCounts {
15
+ passed: number;
16
+ failed: number;
17
+ unknown: number;
18
+ /**
19
+ * Verdicts reached AFTER the journey's first failed step (`--sweep` goes on past it). They are not
20
+ * in `passed` or `failed`, and they count as `unknown` in the score: a step is paid only when every
21
+ * step before it passed. Absent when no step ran after a failure.
22
+ */
23
+ swept?: {
24
+ passed: number;
25
+ failed: number;
26
+ };
27
+ }
28
+ /**
29
+ * The shape of the `--results-json` file. 1 had only `verdict` and `results`; 2 adds `steps` per
30
+ * journey and the gated `score`. A consumer checks this before it reads a field that 1 did not have.
31
+ */
32
+ export declare const RESULTS_SCHEMA_VERSION = 2;
10
33
  export interface VerifyResults {
34
+ schemaVersion: number;
11
35
  verdict: JourneyVerdict;
12
36
  /** `runId` is the run the journey came from, set only when that run reached the platform. */
13
37
  results: {
@@ -15,8 +39,17 @@ export interface VerifyResults {
15
39
  verdict: JourneyVerdict;
16
40
  reason?: string;
17
41
  runId?: string;
42
+ steps: StepCounts;
18
43
  }[];
44
+ /** All journeys together. `value` is passed / total, absent when there is no step at all. */
45
+ score: StepCounts & {
46
+ total: number;
47
+ value?: number;
48
+ };
19
49
  }
20
- /** `pushed`: the run reached the platform, so its id resolves there and the results name it. */
21
- export declare function verifyResults(run: ReticleVerificationRun, pushed?: boolean): VerifyResults;
50
+ /**
51
+ * `pushed`: the run reached the platform, so its id resolves there and the results name it.
52
+ * `planned`: each flow file's step count, by journey name (see `stepCounts`).
53
+ */
54
+ export declare function verifyResults(run: ReticleVerificationRun, pushed?: boolean, planned?: ReadonlyMap<string, number>): VerifyResults;
22
55
  export {};
@@ -6,26 +6,92 @@
6
6
  * never rounded up to `yes`, and the run is `yes` only when every journey is.
7
7
  */
8
8
  import { RunFlowStatus, VerdictStatus, Verified, } from '@reticlehq/core';
9
+ /**
10
+ * The shape of the `--results-json` file. 1 had only `verdict` and `results`; 2 adds `steps` per
11
+ * journey and the gated `score`. A consumer checks this before it reads a field that 1 did not have.
12
+ */
13
+ export const RESULTS_SCHEMA_VERSION = 2;
14
+ /**
15
+ * One journey's steps. `planned` is the flow file's step count, when the caller could read it:
16
+ * without it, a replay that stopped at step 2 of 10 would look like a 2-step journey. Replay adds a
17
+ * `success` step for a flow with a success oracle, so the total is the larger of the two.
18
+ */
19
+ function stepCounts(flow, planned) {
20
+ let passed = 0;
21
+ let failed = 0;
22
+ let judgedUnits = 0;
23
+ let swept;
24
+ for (const step of (flow.stepResults ?? [])) {
25
+ // A sequence counts its sub-steps; any other step counts once.
26
+ const units = step.subSteps?.total ?? 1;
27
+ judgedUnits += units;
28
+ const ok = step.ok && step.drift === undefined;
29
+ // Gated: once a step failed, later verdicts are reported apart and never paid.
30
+ if (swept !== undefined) {
31
+ if (ok) {
32
+ swept.passed += units;
33
+ }
34
+ else {
35
+ swept.passed += step.subSteps?.passed ?? 0;
36
+ swept.failed += 1;
37
+ }
38
+ continue;
39
+ }
40
+ if (ok) {
41
+ passed += units;
42
+ }
43
+ else {
44
+ passed += step.subSteps?.passed ?? 0;
45
+ failed += 1;
46
+ swept = { passed: 0, failed: 0 };
47
+ }
48
+ }
49
+ const total = Math.max(planned ?? flow.steps, judgedUnits);
50
+ const counts = { passed, failed, unknown: total - passed - failed };
51
+ if (swept !== undefined && swept.passed + swept.failed > 0) {
52
+ counts.swept = swept;
53
+ }
54
+ return counts;
55
+ }
9
56
  function journeyVerdict(status) {
10
- if (RunFlowStatus.FAIL === status)
57
+ if (RunFlowStatus.FAIL === status) {
11
58
  return Verified.NO;
59
+ }
12
60
  // A heal is consequence-verified before it is reported, so it proves as much as a pass.
13
- if (RunFlowStatus.PASS === status || RunFlowStatus.HEALED === status)
61
+ if (RunFlowStatus.PASS === status || RunFlowStatus.HEALED === status) {
14
62
  return Verified.YES;
63
+ }
15
64
  return Verified.UNKNOWN;
16
65
  }
17
- /** `pushed`: the run reached the platform, so its id resolves there and the results name it. */
18
- export function verifyResults(run, pushed = false) {
66
+ /**
67
+ * `pushed`: the run reached the platform, so its id resolves there and the results name it.
68
+ * `planned`: each flow file's step count, by journey name (see `stepCounts`).
69
+ */
70
+ export function verifyResults(run, pushed = false, planned = new Map()) {
19
71
  const results = run.flows.map((flow) => ({
20
72
  journey: flow.name,
21
73
  verdict: journeyVerdict(flow.status),
22
74
  ...(flow.failureReason === undefined ? {} : { reason: flow.failureReason }),
23
75
  ...(pushed ? { runId: run.runId } : {}),
76
+ steps: stepCounts(flow, planned.get(flow.name)),
24
77
  }));
25
- const verdict = results.some((r) => Verified.NO === r.verdict)
26
- ? Verified.NO
27
- : VerdictStatus.PASS === run.verdict.status && results.every((r) => Verified.YES === r.verdict)
28
- ? Verified.YES
29
- : Verified.UNKNOWN;
30
- return { verdict, results };
78
+ const sum = (key) => results.reduce((n, r) => n + r.steps[key], 0);
79
+ const passed = sum('passed');
80
+ const total = passed + sum('failed') + sum('unknown');
81
+ const score = {
82
+ passed,
83
+ failed: sum('failed'),
84
+ unknown: sum('unknown'),
85
+ total,
86
+ ...(0 === total ? {} : { value: passed / total }),
87
+ };
88
+ let verdict = Verified.UNKNOWN;
89
+ if (results.some((r) => Verified.NO === r.verdict)) {
90
+ verdict = Verified.NO;
91
+ }
92
+ else if (VerdictStatus.PASS === run.verdict.status &&
93
+ results.every((r) => Verified.YES === r.verdict)) {
94
+ verdict = Verified.YES;
95
+ }
96
+ return { schemaVersion: RESULTS_SCHEMA_VERSION, verdict, results, score };
31
97
  }
@@ -13,6 +13,7 @@ import { installCrashGuard } from './setup/setup-command.js';
13
13
  import { handleStatus } from './cli/status-fields.js';
14
14
  export { handleStatus, reportStatus } from './cli/status-fields.js';
15
15
  import { reticleStateHome } from './daemon/daemon.js';
16
+ import { ensureDaemon } from './cli/ensure-daemon.js';
16
17
  import { handleMcp } from './cli/mcp-command.js';
17
18
  import { handleReport } from './cli/report-command.js';
18
19
  import { handleContext } from './cli/context-command.js';
@@ -24,7 +25,7 @@ import { exitAfterClose } from './daemon/lifetime/daemon-exit.js';
24
25
  import { isSameOrAbove, portForInit, portFromEnv } from './setup/init/init-port.js';
25
26
  import { daemonStartOptions } from './cli/daemon-start-options.js';
26
27
  import { handleGate, loadNamedFlows, resolveChangedFiles } from './cli/cli-flow-commands.js';
27
- import { RETICLE_DEFAULT_PORT, ReticleDir, ReticleEnv } from '@reticlehq/core';
28
+ import { DaemonStartupError, RETICLE_DEFAULT_PORT, ReticleDir, ReticleEnv } from '@reticlehq/core';
28
29
  import { loadDotEnv } from '../telemetry/dev-repo.js';
29
30
  import { licenseKeyFromEnvFiles } from '../features/license/license-env.js';
30
31
  import { LICENSE_KEY_ENV } from '../features/license/license.js';
@@ -36,20 +37,22 @@ import { startDaemon } from '../index.js';
36
37
  import { cmdLink, connectInstalls, isCloudCommand, runCloudCommand } from './cli/cloud-cli.js';
37
38
  import { SERVER_VERSION } from './version/identity/server-version.js';
38
39
  import { log } from '../log.js';
39
- import { isAlive, removePid, spawnDaemon, discoverDaemonPort, writeDaemonRegistry, } from './daemon/daemon.js';
40
- import { PortPresence, probePresence, presenceIsUsable } from './daemon/binding/port-presence.js';
41
- import { describePortState } from './daemon/port-state.js';
42
- import { waitForDaemon, probeDaemon } from '../surface/mcp/mcp-proxy.js';
40
+ import { isAlive, removePid, discoverDaemonPort, writeDaemonRegistry } from './daemon/daemon.js';
41
+ import { probePresence, presenceIsUsable } from './daemon/binding/port-presence.js';
42
+ import { probeDaemon } from '../surface/mcp/mcp-proxy.js';
43
43
  import { installDaemonResilience, recordExitReason, DaemonExitReason, } from './daemon/daemon-resilience.js';
44
44
  import { IdleShutdown, resolveIdleShutdownMs, resolveIdleCheckMs } from './daemon/idle-shutdown.js';
45
45
  import { DaemonHeartbeat, resolveHeartbeatMs } from './daemon/lifetime/heartbeat.js';
46
46
  import { everServedToolCall } from './daemon/lifetime/daemon-usefulness.js';
47
- import { DAEMON_START_FAILED_EVENT } from './daemon/lifetime/startup-failure.js';
48
- import { summarizeStatus, warnOnDaemonSkew, decideOpen, openInBrowser, openCommand, } from './cli/launch/cli-launch.js';
47
+ import { installStartupTrap, recordStartupFailure } from './daemon/lifetime/startup-failure.js';
48
+ import { summarizeStatus, decideOpen, openInBrowser, openCommand, } from './cli/launch/cli-launch.js';
49
49
  import { NAVIGATE_NO_SESSION_ID, navigateLeftTab, openCandidates } from './cli/open-navigate.js';
50
50
  import { fetchStatus } from './daemon/binding/daemon-status-probe.js';
51
51
  import { handleDrive } from './cli/drive/drive-command.js';
52
52
  import { handleVerify } from './cli/cli-verify.js';
53
+ import { handleVerifyAgainst } from './cli/verify-against.js';
54
+ import { handleCompare } from './cli/two-builds/compare-command.js';
55
+ import { handleGrade } from './cli/finished-run/grade-command.js';
53
56
  import { FIRST_VERDICT_REASON, bareRunsFirstVerdict } from './cli/first-verdict.js';
54
57
  import { cmdFirstVerdict } from './cli/first-verdict-node.js';
55
58
  import { reportOnboardingStop } from '../telemetry/onboarding-funnel.js';
@@ -63,7 +66,7 @@ import { serverInitHost } from './setup/init/init-host.js';
63
66
  import { describeLicense } from '../features/license/license.js';
64
67
  import { isLikelyDevServerPort, devServerPortWarning, readProjectPort, readProjectId, workspacePortConflict, projectDirOf, } from './cli/ports/resolve/cli-port.js';
65
68
  import { helpRunsAt, helpVersionNote, nodeSpawner, readTextFile, reexecAtVersion, versionMatchNote, versionToMatch, } from './cli/launch/sdk-version-match.js';
66
- import { DAEMON_INNER_COMMAND, PORT_FLAG, parseCliArgs, CLI_USAGE, dialsTheDaemon, } from './cli/cli-parse.js';
69
+ import { PORT_FLAG, parseCliArgs, CLI_USAGE, dialsTheDaemon } from './cli/cli-parse.js';
67
70
  import { renderHelp } from './cli/cli-usage.js';
68
71
  import { RENAMED_COMMANDS, renamedCommandFor, renamedNote } from './cli/cli-parse-grammar.js';
69
72
  import { handleFeedback, handleTelemetry } from '../telemetry/feedback-cli.js';
@@ -162,12 +165,12 @@ import { handleServe, handleStop, handleRestart } from './cli/lifecycle/daemon-l
162
165
  function handleVersion() {
163
166
  process.stdout.write(`${SERVER_VERSION}\n`);
164
167
  }
165
- /** `reticle license` — show enterprise activation resolved from the environment (offline; nothing leaves). */
168
+ /** `reticle license` (now `status`) — show enterprise activation resolved from the environment (offline; nothing leaves). */
166
169
  function handleLicense() {
167
170
  log('reticle_license', { ...describeLicense(Date.now()) });
168
171
  }
169
172
  /**
170
- * `reticle affected <file...>` — print which saved flows must re-verify for the given changed files.
173
+ * `reticle gate --list <file...>` (was `affected`) — print which saved flows must re-verify for the given changed files.
171
174
  * Environment-side (costs the agent nothing per turn); the foundation of watch/gate. Never throws:
172
175
  * a load error is logged, not fatal.
173
176
  */
@@ -191,32 +194,6 @@ async function handleAffected(files, since) {
191
194
  });
192
195
  }
193
196
  }
194
- /** Ensure a daemon is reachable on `port` (probe the real port; spawn + wait only if nothing's there). */
195
- function ensureDaemon(port) {
196
- return probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus }).then(async (presence) => {
197
- // Attaching to whatever already owns the port is the whole point of a daemon — but it means an
198
- // upgrade does NOT take effect until that daemon dies, and nothing used to say so. Say it here,
199
- // where both versions are in hand, and keep attaching: killing another agent's daemon on a
200
- // version bump is worse than a loud warning.
201
- if (presenceIsUsable(presence)) {
202
- return warnOnDaemonSkew(port);
203
- }
204
- // Not free, not a daemon: a stranger holds the port. Spawning here is guaranteed to fail —
205
- // the child cannot bind — and `waitForDaemon` would then report READY anyway, because its
206
- // probe is the same bare TCP connect and the stranger accepts. That is the "serve reports
207
- // success for a daemon that never bound" shape. Refuse instead, with the sentence `doctor`
208
- // says.
209
- if (PortPresence.FREE !== presence) {
210
- throw new Error(describePortState(presence, port));
211
- }
212
- const scriptPath = process.argv[1];
213
- if (scriptPath === undefined) {
214
- throw new Error('cannot locate the reticle daemon script');
215
- }
216
- spawnDaemon(process.execPath, scriptPath, [DAEMON_INNER_COMMAND, PORT_FLAG, String(port)], port);
217
- return waitForDaemon(port);
218
- });
219
- }
220
197
  /**
221
198
  * `reticle open [url]` — the one-command "show me the app". Resolves the port (the requested one if a
222
199
  * daemon's there, else a running daemon it discovers — so the user never hunts for the port), ensures
@@ -357,8 +334,12 @@ function handleDaemonInner(parsed) {
357
334
  hudSignIn: (repaint, link) => hudSignIn(repaint, undefined, undefined, link),
358
335
  hudLink: (root) => cmdLink([], root, PlatformLinkVia.HUD),
359
336
  };
337
+ // Until the daemon is ready, a throw or a rejection is a failed start. Write it to the log with
338
+ // its code, so the next start can say why this one died (#2043).
339
+ const disposeStartupTrap = installStartupTrap(process, log);
360
340
  startDaemon(options)
361
341
  .then((server) => {
342
+ disposeStartupTrap();
362
343
  log('reticle_daemon_ready', { port: parsed.port, pid: process.pid });
363
344
  // Daemon lifecycle telemetry: started, a one-shot project profile, the periodic counter flush,
364
345
  // and the rich summary on shutdown. All of it lives in one module — see daemon-telemetry.ts.
@@ -443,8 +424,8 @@ function handleDaemonInner(parsed) {
443
424
  }).start();
444
425
  })
445
426
  .catch((error) => {
446
- const message = error instanceof Error ? error.message : String(error);
447
- log(DAEMON_START_FAILED_EVENT, { error: message });
427
+ disposeStartupTrap();
428
+ recordStartupFailure(log, error, DaemonStartupError.START);
448
429
  removePid(parsed.port);
449
430
  process.exit(1);
450
431
  });
@@ -522,7 +503,7 @@ export function main() {
522
503
  const colour = true === process.stderr.isTTY && process.env['NO_COLOR'] === undefined;
523
504
  process.stderr.write(`${renamedNote(argv[0] ?? '', renamedTo, colour)}\n`);
524
505
  }
525
- // Cloud subcommands (login/link/project/config/push) are a distinct family with their own async client;
506
+ // Cloud subcommands (connect/sync/runs/config) are a distinct family with their own async client;
526
507
  // handle them before the local typed parser so `reticle login` etc. work as one tool.
527
508
  if (isCloudCommand(argv[0])) {
528
509
  // Asking a command what it does is never a request to run it. These dispatch before the typed
@@ -651,7 +632,8 @@ export function main() {
651
632
  void handleRestart(parsed.port, parsed.force);
652
633
  break;
653
634
  case 'status':
654
- void handleStatus(parsed.port, parsed.json);
635
+ // Offline: the license key is read from the environment.
636
+ void handleStatus(parsed.port, parsed.json, { ...describeLicense(Date.now()) });
655
637
  break;
656
638
  case 'license':
657
639
  handleLicense();
@@ -699,12 +681,35 @@ export function main() {
699
681
  handleDrive(parsed, readProjectId(process.cwd()));
700
682
  break;
701
683
  case 'verify':
702
- void writeFirstUseWarnings().then(() => {
684
+ void writeFirstUseWarnings().then(async () => {
685
+ // `--against` compares two builds through the daemon, and starts one when there is none.
686
+ if (parsed.against !== undefined) {
687
+ await handleVerifyAgainst({ ...parsed, against: parsed.against }, ensureDaemon, {
688
+ line: (text) => process.stdout.write(`${text}\n`),
689
+ fail: (text) => process.stderr.write(`${text}\n`),
690
+ exit: (code) => process.exit(code),
691
+ });
692
+ return;
693
+ }
703
694
  handleVerify(parsed, () => {
704
695
  writeCiHint({ automation: currentAutomationHint(process.env), machineOutput: false });
705
696
  });
706
697
  });
707
698
  break;
699
+ case 'compare':
700
+ // Two builds through the daemon, which it starts when there is none. Nothing to install.
701
+ void handleCompare(parsed, ensureDaemon, {
702
+ line: (text) => process.stdout.write(`${text}\n`),
703
+ exit: (code) => process.exit(code),
704
+ });
705
+ break;
706
+ case 'grade':
707
+ // Reads the declared state sources and snapshot files. No daemon and no browser.
708
+ void handleGrade(parsed.args, {
709
+ line: (text) => process.stdout.write(`${text}\n`),
710
+ exit: (code) => process.exit(code),
711
+ });
712
+ break;
708
713
  case 'affected':
709
714
  void handleAffected(parsed.files, parsed.since).then(() => {
710
715
  writeCiHint({ automation: currentAutomationHint(process.env), machineOutput: false });
@@ -98,7 +98,14 @@ export declare function projectlessNote(myPort: number, others: readonly {
98
98
  * record naming a daemon that is not the one being asked. The wording says which side is empty,
99
99
  * since the failure is invisible precisely because both sides look healthy from where they stand.
100
100
  */
101
- export declare function splitBrainNote(myPort: number, elsewhere: readonly number[]): string | undefined;
101
+ export declare function splitBrainNote(myPort: number, elsewhere: readonly number[],
102
+ /** This project and the registry home, to tell whether the app's daemon belongs to another project. */
103
+ owner?: {
104
+ projectId: string | undefined;
105
+ home: string;
106
+ },
107
+ /** Where `RETICLE_PORT` is read from; injectable so the note is testable. */
108
+ env?: Readonly<Record<string, string | undefined>>): string | undefined;
102
109
  /**
103
110
  * The other stance on the same split, for a command standing on the port that HAS the app.
104
111
  *
@@ -20,6 +20,7 @@ import { readdirSync, readFileSync } from 'node:fs';
20
20
  import { join } from 'node:path';
21
21
  import { DaemonRegistryEntrySchema, daemonRegistryPort, pickDaemonPort, } from '@reticlehq/core/artifacts';
22
22
  import { ReticleEnv, RETICLE_DEFAULT_PORT } from '@reticlehq/core';
23
+ import { portFromEnv } from '../setup/init/init-port.js';
23
24
  const RETICLE_PORT_ENV = ReticleEnv.PORT;
24
25
  /**
25
26
  * Every well-formed registry entry in `home`.
@@ -151,11 +152,36 @@ export function projectlessNote(myPort, others) {
151
152
  * record naming a daemon that is not the one being asked. The wording says which side is empty,
152
153
  * since the failure is invisible precisely because both sides look healthy from where they stand.
153
154
  */
154
- export function splitBrainNote(myPort, elsewhere) {
155
+ export function splitBrainNote(myPort, elsewhere,
156
+ /** This project and the registry home, to tell whether the app's daemon belongs to another project. */
157
+ owner,
158
+ /** Where `RETICLE_PORT` is read from; injectable so the note is testable. */
159
+ env = process.env) {
155
160
  if (0 === elsewhere.length)
156
161
  return undefined;
157
162
  const listed = elsewhere.map((port) => `:${String(port)}`).join(', ');
158
163
  const first = elsewhere[0] ?? myPort;
164
+ // The app landed on ANOTHER project's daemon (#1652): two projects share a port. `resolveMcpPort`
165
+ // never adopts a daemon registered to a different project, so RETICLE_PORT would only relocate the
166
+ // agent again. The step that works is giving this project its own port, so its app comes here.
167
+ // Owner by port alone is safe: `elsewhere` is already liveness-filtered (`daemonsServingProjectElsewhere`).
168
+ const holder = owner === undefined ? undefined : daemonProjectAt(first, owner.home);
169
+ if (owner !== undefined && holder !== undefined && !adoptable(holder, owner.projectId)) {
170
+ // Port resolution is env-first (`--port`, `RETICLE_PORT`, `.reticle.json`, 4400), so a
171
+ // `RETICLE_PORT` left in the agent's MCP config from the old advice outranks the new `port`.
172
+ const envFirst = portFromEnv(env) === undefined
173
+ ? 'Give this project its own port instead: set'
174
+ : `Give this project its own port instead: first remove ${RETICLE_PORT_ENV} from the agent's ` +
175
+ 'MCP config, because it takes precedence over `.reticle.json`, then set';
176
+ return (`this project's app is connected to the Reticle daemon on :${String(first)}, which is ` +
177
+ `registered to another project, "${holder}", while this project's daemon is on ` +
178
+ `:${String(myPort)} — the two projects share a port, and the agent is attached to the empty ` +
179
+ `half. Pointing the agent at :${String(first)} will not help: an MCP server never attaches to ` +
180
+ `another project's daemon. ${envFirst} \`port\` in its ` +
181
+ `.reticle.json to one no other project uses (for example ${String(myPort)}), then restart the ` +
182
+ `dev server so the app connects to this project's daemon, and restart the agent session. ` +
183
+ `Leave :${String(first)} running; it serves "${holder}".`);
184
+ }
159
185
  // The agent moves, not the app (#1546). The daemon the app is on is a working session this agent
160
186
  // did not start, and it may be a person's, another agent's or another project's. Stopping it was
161
187
  // the first thing this used to say. It comes last now, with what it costs.
@@ -55,13 +55,22 @@ export declare function isRunning(port: number): boolean;
55
55
  * (lowest port, deterministic), or null when none is running.
56
56
  */
57
57
  export declare function discoverDaemonPort(): number | null;
58
+ /**
59
+ * Does this pidfile name a daemon that is still running?
60
+ *
61
+ * The pid must be alive AND the file must be newer than the last boot. A pid alone is not enough:
62
+ * the OS reuses pids, and after a reboot a pidfile named an unrelated Bluetooth service on Windows.
63
+ * The port was free, but serve skipped the start because that pid was alive, and the daemon never
64
+ * came up. A file whose age cannot be read is judged by its pid alone, as before.
65
+ */
66
+ export declare function pidFileIsLive(path: string, pid: number, pidAlive: (pid: number) => boolean, bootMs?: () => number): boolean;
58
67
  /**
59
68
  * Sweep ~/.reticle for daemon-<port>.pid files whose process is no longer alive and delete them, so a
60
69
  * crashed daemon never leaves a stale pidfile that confuses discovery or makes a port look "taken".
61
70
  * Returns the ports reclaimed. `home` and `pidAlive` are injectable for testing (default to the real
62
71
  * ~/.reticle and the process.kill(pid,0) liveness probe).
63
72
  */
64
- export declare function reclaimStaleDaemons(home?: string, pidAlive?: (pid: number) => boolean): number[];
73
+ export declare function reclaimStaleDaemons(home?: string, pidAlive?: (pid: number) => boolean, bootMs?: () => number): number[];
65
74
  /** The minimal shape of a spawned child that spawnDaemon uses. */
66
75
  export interface SpawnedChild {
67
76
  readonly pid?: number | undefined;
@@ -82,6 +91,8 @@ export interface SpawnDaemonDeps {
82
91
  env?: Record<string, string | undefined>;
83
92
  }): SpawnedChild;
84
93
  pidAlive(pid: number): boolean;
94
+ /** When the machine last booted, epoch ms. Optional: the default reads `os.uptime()`. */
95
+ readonly bootTimeMs?: () => number;
85
96
  /** `process.platform`. Optional so a test that does not care runs the detached start. */
86
97
  readonly platform?: string;
87
98
  /** Windows only: start the daemon outside the Job Object of this process. See win32-escape.ts. */