@reticlehq/server 2.12.0 → 2.13.1

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 (386) hide show
  1. package/SKILL.md +67 -109
  2. package/dist/bridge/auth-failure-reason.d.ts +6 -2
  3. package/dist/bridge/auth-failure-reason.js +15 -8
  4. package/dist/bridge/bridge.d.ts +18 -1
  5. package/dist/bridge/bridge.js +247 -164
  6. package/dist/bridge/pairing-token.d.ts +5 -0
  7. package/dist/bridge/pairing-token.js +28 -0
  8. package/dist/bridge/protocol-skew.d.ts +37 -0
  9. package/dist/bridge/protocol-skew.js +45 -0
  10. package/dist/capsule/capsule-store.d.ts +6 -6
  11. package/dist/capsule/causal-summary.js +7 -2
  12. package/dist/cli/cli-doctor.js +31 -1
  13. package/dist/cli/cli-launch.d.ts +11 -1
  14. package/dist/cli/cli-launch.js +59 -10
  15. package/dist/cli/cli-parse.d.ts +15 -1
  16. package/dist/cli/cli-parse.js +212 -8
  17. package/dist/cli/cli-verify.d.ts +1 -0
  18. package/dist/cli/cli-verify.js +3 -2
  19. package/dist/cli/cloud-cli.js +428 -259
  20. package/dist/cli/cloud-keystore.d.ts +37 -0
  21. package/dist/cli/cloud-keystore.js +97 -0
  22. package/dist/cli/cloud-kit.d.ts +37 -0
  23. package/dist/cli/cloud-kit.js +121 -0
  24. package/dist/cli/cloud-login.d.ts +17 -0
  25. package/dist/cli/cloud-login.js +183 -0
  26. package/dist/cli/cloud-session.d.ts +48 -0
  27. package/dist/cli/cloud-session.js +53 -0
  28. package/dist/cli/config-discovery.js +1 -0
  29. package/dist/cli/daemon-start-options.d.ts +32 -0
  30. package/dist/cli/daemon-start-options.js +38 -0
  31. package/dist/cli/device-flow.d.ts +76 -0
  32. package/dist/cli/device-flow.js +56 -0
  33. package/dist/cli/doctor-rows.d.ts +9 -0
  34. package/dist/cli/doctor-rows.js +9 -0
  35. package/dist/cli/drive-attach.d.ts +13 -0
  36. package/dist/cli/drive-attach.js +18 -0
  37. package/dist/cli/drive-command.js +7 -3
  38. package/dist/cli/mcp-command.js +51 -20
  39. package/dist/cli/project-name.d.ts +30 -0
  40. package/dist/cli/project-name.js +44 -0
  41. package/dist/cli/status-next-action.d.ts +14 -0
  42. package/dist/cli/status-next-action.js +4 -4
  43. package/dist/cli.d.ts +1 -0
  44. package/dist/cli.js +134 -80
  45. package/dist/cloud/cloud-config.d.ts +22 -1
  46. package/dist/cloud/cloud-config.js +64 -5
  47. package/dist/cloud/cloud-sync.d.ts +5 -5
  48. package/dist/cloud/cloud-sync.js +33 -2
  49. package/dist/cloud/sync-cycle.d.ts +110 -0
  50. package/dist/cloud/sync-cycle.js +270 -0
  51. package/dist/cloud/sync-daemon.d.ts +69 -0
  52. package/dist/cloud/sync-daemon.js +260 -0
  53. package/dist/cloud/sync-disk.d.ts +9 -0
  54. package/dist/cloud/sync-disk.js +191 -0
  55. package/dist/cloud/sync-hash.d.ts +2 -0
  56. package/dist/cloud/sync-hash.js +35 -0
  57. package/dist/crawl/crawl-tools.js +5 -3
  58. package/dist/crawl/crawl.d.ts +3 -0
  59. package/dist/crawl/crawl.js +5 -0
  60. package/dist/daemon/daemon-resolve.d.ts +43 -0
  61. package/dist/daemon/daemon-resolve.js +69 -0
  62. package/dist/daemon/dev-servers.d.ts +15 -0
  63. package/dist/daemon/dev-servers.js +68 -0
  64. package/dist/daemon/wake-decision.d.ts +32 -0
  65. package/dist/daemon/wake-decision.js +38 -0
  66. package/dist/domain/domain-tools.js +2 -2
  67. package/dist/events/contradictions.d.ts +18 -0
  68. package/dist/events/contradictions.js +134 -21
  69. package/dist/events/declared.d.ts +7 -4
  70. package/dist/events/declared.js +15 -6
  71. package/dist/events/echo-mismatch.js +64 -14
  72. package/dist/events/event-filters.d.ts +8 -0
  73. package/dist/events/event-filters.js +19 -2
  74. package/dist/events/predicate-eval.js +53 -5
  75. package/dist/events/predicate-precheck.d.ts +8 -0
  76. package/dist/events/predicate-precheck.js +41 -0
  77. package/dist/events/predicate-route.d.ts +14 -0
  78. package/dist/events/predicate-route.js +36 -8
  79. package/dist/events/predicate.d.ts +5 -0
  80. package/dist/events/predicate.js +37 -5
  81. package/dist/events/split-text-miss.d.ts +30 -0
  82. package/dist/events/split-text-miss.js +44 -0
  83. package/dist/flows/flake.d.ts +6 -6
  84. package/dist/flows/flow-memory-consult.d.ts +55 -0
  85. package/dist/flows/flow-memory-consult.js +38 -0
  86. package/dist/flows/flow-replay-run.d.ts +18 -7
  87. package/dist/flows/flow-replay-run.js +239 -22
  88. package/dist/flows/flow-replay.js +11 -2
  89. package/dist/flows/flow-step-runners.d.ts +8 -0
  90. package/dist/flows/flow-step-runners.js +8 -2
  91. package/dist/flows/flow-store-for-session.d.ts +31 -0
  92. package/dist/flows/flow-store-for-session.js +33 -0
  93. package/dist/flows/flow-tools.js +58 -32
  94. package/dist/flows/flows.d.ts +8 -0
  95. package/dist/flows/flows.js +468 -2
  96. package/dist/flows/heal-run.js +5 -2
  97. package/dist/flows/predicate-to-expect.d.ts +17 -12
  98. package/dist/flows/predicate-to-expect.js +30 -12
  99. package/dist/flows/recordings.d.ts +13 -1
  100. package/dist/flows/recordings.js +17 -2
  101. package/dist/flows/replay.d.ts +22 -2
  102. package/dist/flows/replay.js +28 -2
  103. package/dist/flows/verify-change-tools.js +5 -2
  104. package/dist/honesty/blind-spots.d.ts +55 -2
  105. package/dist/honesty/blind-spots.js +63 -3
  106. package/dist/honesty/honesty.d.ts +24 -0
  107. package/dist/honesty/honesty.js +25 -1
  108. package/dist/honesty/instrumentation-gaps.d.ts +9 -0
  109. package/dist/honesty/instrumentation-gaps.js +29 -2
  110. package/dist/honesty/verified.d.ts +2 -1
  111. package/dist/honesty/verified.js +16 -1
  112. package/dist/http-server.js +12 -0
  113. package/dist/impact/impact-recorder.d.ts +31 -8
  114. package/dist/impact/impact-recorder.js +104 -21
  115. package/dist/impact/impact-store.d.ts +15 -1
  116. package/dist/impact/impact-store.js +43 -2
  117. package/dist/index.js +113 -7
  118. package/dist/init/agent-rules.d.ts +13 -6
  119. package/dist/init/agent-rules.js +23 -12
  120. package/dist/init/astro-patch.d.ts +16 -0
  121. package/dist/init/astro-patch.js +28 -0
  122. package/dist/init/capabilities.d.ts +4 -1
  123. package/dist/init/capabilities.js +22 -2
  124. package/dist/init/confirm.d.ts +31 -7
  125. package/dist/init/confirm.js +48 -8
  126. package/dist/init/cra.d.ts +20 -4
  127. package/dist/init/cra.js +57 -17
  128. package/dist/init/csp-check.js +17 -2
  129. package/dist/init/csp-doctor.d.ts +8 -1
  130. package/dist/init/csp-doctor.js +8 -3
  131. package/dist/init/dev-script.d.ts +3 -1
  132. package/dist/init/dev-script.js +8 -5
  133. package/dist/init/format-generated.d.ts +18 -0
  134. package/dist/init/format-generated.js +39 -0
  135. package/dist/init/install-hint.d.ts +9 -0
  136. package/dist/init/install-hint.js +29 -0
  137. package/dist/init/mcp-clients.d.ts +7 -0
  138. package/dist/init/mcp-clients.js +7 -0
  139. package/dist/init/mcp.d.ts +1 -1
  140. package/dist/init/mcp.js +1 -1
  141. package/dist/init/node-io.js +11 -1
  142. package/dist/init/plan-framework.js +81 -45
  143. package/dist/init/plan.d.ts +20 -7
  144. package/dist/init/plan.js +20 -45
  145. package/dist/init/preflight.d.ts +28 -0
  146. package/dist/init/preflight.js +38 -0
  147. package/dist/init/run.d.ts +32 -9
  148. package/dist/init/run.js +141 -119
  149. package/dist/init/slash-command.d.ts +1 -1
  150. package/dist/init/snippets.js +41 -25
  151. package/dist/init/vite-config.d.ts +1 -1
  152. package/dist/init/vite-config.js +39 -25
  153. package/dist/init/workspace-apps.d.ts +17 -10
  154. package/dist/init/workspace-apps.js +110 -0
  155. package/dist/input/network-mock-tools.js +3 -6
  156. package/dist/input/real-input.js +25 -5
  157. package/dist/input/scroll-tools.js +4 -6
  158. package/dist/input/viewport-tools.js +5 -4
  159. package/dist/intent/inline-intent.d.ts +10 -19
  160. package/dist/intent/inline-intent.js +91 -1
  161. package/dist/intent/intent-shard-store.d.ts +72 -0
  162. package/dist/intent/intent-shard-store.js +203 -0
  163. package/dist/intent/intent-shard.d.ts +411 -0
  164. package/dist/intent/intent-shard.js +176 -0
  165. package/dist/intent/intent-store.d.ts +12 -0
  166. package/dist/intent/intent-store.js +21 -0
  167. package/dist/intent/intent-subject.d.ts +45 -0
  168. package/dist/intent/intent-subject.js +177 -0
  169. package/dist/intent/intent-tools.js +65 -2
  170. package/dist/intent/open-intents.d.ts +21 -0
  171. package/dist/intent/open-intents.js +15 -0
  172. package/dist/license/license-env.d.ts +29 -0
  173. package/dist/license/license-env.js +148 -0
  174. package/dist/loopback-agent.d.ts +31 -0
  175. package/dist/loopback-agent.js +57 -0
  176. package/dist/mcp/attach-memory.d.ts +18 -2
  177. package/dist/mcp/attach-memory.js +50 -7
  178. package/dist/mcp/client-identity.d.ts +31 -0
  179. package/dist/mcp/client-identity.js +51 -0
  180. package/dist/mcp/mcp-post-transport.d.ts +26 -0
  181. package/dist/mcp/mcp-post-transport.js +109 -0
  182. package/dist/mcp/mcp-proxy.d.ts +2 -1
  183. package/dist/mcp/mcp-proxy.js +52 -62
  184. package/dist/mcp/mcp.js +7 -1
  185. package/dist/mcp/proxy-backoff.d.ts +11 -0
  186. package/dist/mcp/proxy-backoff.js +13 -0
  187. package/dist/mcp/proxy-daemon-probe.d.ts +1 -0
  188. package/dist/mcp/proxy-daemon-probe.js +7 -1
  189. package/dist/mcp/proxy-handshake.d.ts +23 -0
  190. package/dist/mcp/proxy-handshake.js +31 -0
  191. package/dist/mcp/tool-catalog-cache.d.ts +8 -0
  192. package/dist/mcp/tool-catalog-cache.js +12 -0
  193. package/dist/memory/memory-tools.d.ts +2 -0
  194. package/dist/memory/memory-tools.js +82 -0
  195. package/dist/memory/project-memory.d.ts +34 -0
  196. package/dist/memory/project-memory.js +96 -0
  197. package/dist/oracles/propose-consequences.js +12 -3
  198. package/dist/pool/browser-pool.d.ts +63 -4
  199. package/dist/pool/browser-pool.js +113 -4
  200. package/dist/pool/playwright-launcher.js +9 -0
  201. package/dist/project/project-tools.js +3 -3
  202. package/dist/project/reticle-dir.d.ts +15 -2
  203. package/dist/project/reticle-dir.js +21 -4
  204. package/dist/runs/run-store.d.ts +10 -0
  205. package/dist/runs/run-store.js +10 -0
  206. package/dist/runs/verification-sync.d.ts +0 -11
  207. package/dist/runs/verification-sync.js +22 -1
  208. package/dist/runs/verify-server.d.ts +8 -1
  209. package/dist/runs/verify-server.js +14 -2
  210. package/dist/session/command-timeout.d.ts +5 -2
  211. package/dist/session/command-timeout.js +5 -4
  212. package/dist/session/lease-visibility.d.ts +67 -0
  213. package/dist/session/lease-visibility.js +76 -0
  214. package/dist/session/no-session-diagnosis.d.ts +23 -0
  215. package/dist/session/no-session-diagnosis.js +58 -17
  216. package/dist/session/no-session-next-action.d.ts +7 -0
  217. package/dist/session/no-session-next-action.js +42 -3
  218. package/dist/session/no-session-watch.d.ts +10 -6
  219. package/dist/session/no-session-watch.js +51 -19
  220. package/dist/session/pending-commands.d.ts +11 -1
  221. package/dist/session/pending-commands.js +12 -2
  222. package/dist/session/resolve-within.d.ts +34 -0
  223. package/dist/session/resolve-within.js +53 -0
  224. package/dist/session/session-info.d.ts +18 -0
  225. package/dist/session/session-info.js +11 -0
  226. package/dist/session/session-manager.d.ts +14 -0
  227. package/dist/session/session-manager.js +61 -3
  228. package/dist/session/session-mirror.d.ts +16 -0
  229. package/dist/session/session-mirror.js +22 -0
  230. package/dist/session/session-recommendation.d.ts +19 -5
  231. package/dist/session/session-recommendation.js +25 -9
  232. package/dist/session/session-successor.js +36 -1
  233. package/dist/session/session-tools.js +2 -2
  234. package/dist/session/session.d.ts +22 -2
  235. package/dist/session/session.js +87 -7
  236. package/dist/session/wall-clock.d.ts +12 -0
  237. package/dist/session/wall-clock.js +14 -0
  238. package/dist/setup/agent-configs.d.ts +96 -0
  239. package/dist/setup/agent-configs.js +333 -0
  240. package/dist/setup/agent-io.d.ts +9 -0
  241. package/dist/setup/agent-io.js +16 -0
  242. package/dist/setup/agent-writer.d.ts +51 -0
  243. package/dist/setup/agent-writer.js +115 -0
  244. package/dist/setup/approval-migration.d.ts +47 -0
  245. package/dist/setup/approval-migration.js +68 -0
  246. package/dist/setup/auto-approve.d.ts +85 -0
  247. package/dist/setup/auto-approve.js +150 -0
  248. package/dist/setup/bridge-port.d.ts +15 -0
  249. package/dist/setup/bridge-port.js +24 -0
  250. package/dist/setup/desktop-shape.d.ts +57 -0
  251. package/dist/setup/desktop-shape.js +63 -0
  252. package/dist/setup/dev-server-wait.d.ts +68 -0
  253. package/dist/setup/dev-server-wait.js +67 -0
  254. package/dist/setup/drive-agent.d.ts +71 -0
  255. package/dist/setup/drive-agent.js +171 -0
  256. package/dist/setup/drive-plan.d.ts +68 -0
  257. package/dist/setup/drive-plan.js +130 -0
  258. package/dist/setup/ensure-daemon.d.ts +29 -0
  259. package/dist/setup/ensure-daemon.js +58 -0
  260. package/dist/setup/init-runtime.d.ts +47 -0
  261. package/dist/setup/init-runtime.js +160 -0
  262. package/dist/setup/interrupt.d.ts +25 -0
  263. package/dist/setup/interrupt.js +40 -0
  264. package/dist/setup/license-key.d.ts +39 -0
  265. package/dist/setup/license-key.js +72 -0
  266. package/dist/setup/listeners.d.ts +37 -0
  267. package/dist/setup/listeners.js +85 -0
  268. package/dist/setup/node-effects.d.ts +60 -0
  269. package/dist/setup/node-effects.js +210 -0
  270. package/dist/setup/page-probe.d.ts +39 -0
  271. package/dist/setup/page-probe.js +49 -0
  272. package/dist/setup/relaunch.d.ts +36 -0
  273. package/dist/setup/relaunch.js +57 -0
  274. package/dist/setup/remaining-steps.d.ts +19 -0
  275. package/dist/setup/remaining-steps.js +31 -0
  276. package/dist/setup/run-setup.d.ts +99 -0
  277. package/dist/setup/run-setup.js +203 -0
  278. package/dist/setup/session-pick.d.ts +47 -0
  279. package/dist/setup/session-pick.js +51 -0
  280. package/dist/setup/setup-command.d.ts +45 -0
  281. package/dist/setup/setup-command.js +199 -0
  282. package/dist/setup/setup-options.d.ts +74 -0
  283. package/dist/setup/setup-options.js +67 -0
  284. package/dist/setup/transcripts.d.ts +16 -0
  285. package/dist/setup/transcripts.js +43 -0
  286. package/dist/status-payload.d.ts +14 -1
  287. package/dist/status-payload.js +23 -1
  288. package/dist/telemetry/daemon-telemetry.d.ts +8 -1
  289. package/dist/telemetry/daemon-telemetry.js +52 -5
  290. package/dist/telemetry/error-class.js +1 -1
  291. package/dist/telemetry/feedback-context.d.ts +9 -1
  292. package/dist/telemetry/feedback-context.js +60 -29
  293. package/dist/telemetry/feedback-local.d.ts +15 -0
  294. package/dist/telemetry/feedback-local.js +56 -0
  295. package/dist/telemetry/feedback.d.ts +11 -0
  296. package/dist/telemetry/feedback.js +19 -6
  297. package/dist/telemetry/init-telemetry.d.ts +2 -0
  298. package/dist/telemetry/init-telemetry.js +2 -0
  299. package/dist/telemetry/install-source.d.ts +14 -0
  300. package/dist/telemetry/install-source.js +30 -0
  301. package/dist/telemetry/license-activation.d.ts +7 -1
  302. package/dist/telemetry/license-activation.js +9 -5
  303. package/dist/telemetry/mcp-connection.js +7 -0
  304. package/dist/telemetry/project-profile.d.ts +12 -1
  305. package/dist/telemetry/project-profile.js +11 -6
  306. package/dist/telemetry/proxy-telemetry.d.ts +3 -0
  307. package/dist/telemetry/proxy-telemetry.js +21 -0
  308. package/dist/telemetry/session-metrics.d.ts +12 -0
  309. package/dist/telemetry/session-metrics.js +35 -1
  310. package/dist/telemetry/telemetry.js +17 -2
  311. package/dist/telemetry/tool-refused.d.ts +3 -1
  312. package/dist/telemetry/tool-refused.js +34 -2
  313. package/dist/tools/act-capsule.d.ts +10 -0
  314. package/dist/tools/act-capsule.js +8 -2
  315. package/dist/tools/act-danger.d.ts +2 -1
  316. package/dist/tools/act-danger.js +11 -3
  317. package/dist/tools/act-observation.d.ts +41 -0
  318. package/dist/tools/act-observation.js +36 -0
  319. package/dist/tools/act-preflight.d.ts +20 -0
  320. package/dist/tools/act-preflight.js +98 -0
  321. package/dist/tools/act-sequence-retry.d.ts +45 -0
  322. package/dist/tools/act-sequence-retry.js +58 -0
  323. package/dist/tools/act-sequence-tool.d.ts +13 -0
  324. package/dist/tools/act-sequence-tool.js +135 -0
  325. package/dist/tools/act-target.d.ts +14 -0
  326. package/dist/tools/act-target.js +47 -0
  327. package/dist/tools/act-tools.d.ts +21 -1
  328. package/dist/tools/act-tools.js +100 -213
  329. package/dist/tools/assert-verdict.js +22 -16
  330. package/dist/tools/browser-tools.js +6 -2
  331. package/dist/tools/dynamic-tools.js +24 -5
  332. package/dist/tools/error-recovery.d.ts +1 -0
  333. package/dist/tools/error-recovery.js +8 -0
  334. package/dist/tools/invoke-tool.js +59 -9
  335. package/dist/tools/lease-tools.d.ts +30 -4
  336. package/dist/tools/lease-tools.js +169 -5
  337. package/dist/tools/merged-name-redirect.d.ts +13 -0
  338. package/dist/tools/merged-name-redirect.js +22 -0
  339. package/dist/tools/numeric-bounds.d.ts +100 -0
  340. package/dist/tools/numeric-bounds.js +125 -0
  341. package/dist/tools/observe-tools.js +47 -40
  342. package/dist/tools/read-tools.js +28 -7
  343. package/dist/tools/real-input-attempt.d.ts +10 -0
  344. package/dist/tools/real-input-attempt.js +59 -1
  345. package/dist/tools/reconcile-tools.js +4 -4
  346. package/dist/tools/tool-kit.d.ts +12 -0
  347. package/dist/tools/tool-kit.js +6 -1
  348. package/dist/tools/tool-names.d.ts +2 -0
  349. package/dist/tools/tool-names.js +2 -0
  350. package/dist/tools/tools-helpers.d.ts +19 -0
  351. package/dist/tools/tools-helpers.js +46 -0
  352. package/dist/tools/tools.d.ts +2 -0
  353. package/dist/tools/tools.js +56 -8
  354. package/dist/update/reticle-deps.d.ts +9 -0
  355. package/dist/update/reticle-deps.js +27 -0
  356. package/dist/update/sdk-sync.d.ts +1 -2
  357. package/dist/update/sdk-sync.js +1 -21
  358. package/dist/update/update-nudge.d.ts +18 -0
  359. package/dist/update/update-nudge.js +20 -0
  360. package/dist/version/peer-announce.js +4 -2
  361. package/dist/version/sdk-fix.d.ts +47 -0
  362. package/dist/version/sdk-fix.js +127 -0
  363. package/dist/version/version-nudge.d.ts +14 -0
  364. package/dist/version/version-nudge.js +21 -0
  365. package/dist/version/version-skew.d.ts +38 -3
  366. package/dist/version/version-skew.js +83 -7
  367. package/dist/visual/visual-store.d.ts +6 -6
  368. package/dist/visual/visual-store.js +18 -16
  369. package/dist/visual/visual-tools.js +57 -12
  370. package/docs/cli/doctor.mdx +1 -0
  371. package/docs/cli/serve.mdx +4 -2
  372. package/docs/cli.mdx +7 -3
  373. package/docs/desktop.mdx +1 -1
  374. package/docs/docs.json +1 -1
  375. package/docs/getting-started.md +19 -3
  376. package/docs/index.mdx +9 -6
  377. package/docs/install-agentic.mdx +38 -12
  378. package/docs/install-manual.mdx +1 -1
  379. package/docs/packages.mdx +1 -1
  380. package/docs/skill-file.mdx +3 -3
  381. package/docs/telemetry-contract.md +108 -4
  382. package/docs/tools-act-sequence.mdx +16 -0
  383. package/docs/usage.md +1 -1
  384. package/package.json +4 -3
  385. package/dist/init/cursor.d.ts +0 -24
  386. package/dist/init/cursor.js +0 -80
@@ -0,0 +1,191 @@
1
+ /**
2
+ * The filesystem half of a sync cycle: what `.reticle/` looks like to the protocol.
3
+ *
4
+ * Kept apart from `sync-cycle.ts` on purpose. The cycle is the interesting part — what to send, what
5
+ * to skip, what to do when the network is down — and it stays provable only because it never touches
6
+ * a disk. This is the boring part, and boring is what it should be: read a file, tolerate its
7
+ * absence, never throw.
8
+ *
9
+ * EVERY READ IS FORGIVING. A half-written impact.json from a process that died mid-flush must cost
10
+ * one unsynced record, not a crashed sync. There is no state here worth defending against a parse
11
+ * error — the file will be rewritten on the next tool call anyway.
12
+ */
13
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync, } from 'node:fs';
14
+ import { dirname, join } from 'node:path';
15
+ import { ReticleDir } from '@reticlehq/core';
16
+ import { subjectFor } from '../intent/intent-subject.js';
17
+ const JSON_SUFFIX = '.json';
18
+ /** Read + parse, or undefined for absent/unreadable/malformed. Never throws. */
19
+ function readJson(path) {
20
+ try {
21
+ return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : undefined;
22
+ }
23
+ catch {
24
+ return undefined;
25
+ }
26
+ }
27
+ /**
28
+ * Write atomically. tmp + rename, because two daemons can serve one repo at once and a half-written
29
+ * cursor file is one the next read discards — which would silently re-pull the whole decision
30
+ * history, or worse, skip it.
31
+ */
32
+ function writeJson(path, value) {
33
+ try {
34
+ mkdirSync(dirname(path), { recursive: true });
35
+ const tmp = `${path}.${String(process.pid)}.tmp`;
36
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
37
+ renameSync(tmp, path);
38
+ }
39
+ catch {
40
+ // Bookkeeping that cannot be written must never break a sync. The next cycle re-derives it; the
41
+ // worst case is one redundant upload, which the server dedupes anyway.
42
+ }
43
+ }
44
+ /** Every `*.json` directly inside a directory, parsed. Absent directory reads as empty. */
45
+ function readJsonDir(dir) {
46
+ try {
47
+ if (!existsSync(dir))
48
+ return [];
49
+ return readdirSync(dir)
50
+ .filter((f) => f.endsWith(JSON_SUFFIX))
51
+ .map((f) => readJson(join(dir, f)))
52
+ .filter((v) => v !== undefined);
53
+ }
54
+ catch {
55
+ return [];
56
+ }
57
+ }
58
+ /** Flows live one directory deeper, under the app's own project id. */
59
+ function readFlows(root) {
60
+ const dir = join(root, ReticleDir.FLOWS_SUBDIR);
61
+ try {
62
+ if (!existsSync(dir))
63
+ return [];
64
+ return readdirSync(dir).flatMap((scope) => readJsonDir(join(dir, scope)));
65
+ }
66
+ catch {
67
+ return [];
68
+ }
69
+ }
70
+ const DERIVED_FILE = {
71
+ impact: ReticleDir.IMPACT_FILE,
72
+ flake: ReticleDir.FLAKE_FILE,
73
+ intent: ReticleDir.INTENT_FILE,
74
+ };
75
+ /** The directory the sharded intent store writes into, beside the legacy flat file. */
76
+ const INTENT_SUBDIR = 'intent';
77
+ const INTENT_INDEX = 'index.json';
78
+ /**
79
+ * Every intent this project holds, flat file and shards merged.
80
+ *
81
+ * The store was split into one file per subject so that writing one record stops rewriting all of
82
+ * them. Sync was not told, and kept reading `intent.json` alone — which the migration deliberately
83
+ * does not delete. The result was the worst shape a sync bug can take: not an error, but a corpus
84
+ * that looked healthy on the dashboard and had silently stopped moving, because every intent
85
+ * captured after the migration stayed on the engineer's laptop.
86
+ *
87
+ * Shards WIN on a collision, matching the store's own rule: an id present in both has been migrated
88
+ * and possibly edited since, and letting the flat copy overwrite that would revert the edit on every
89
+ * cycle. `index.json` is derived from the shards and is skipped — sending it would duplicate every
90
+ * record as a summary of itself.
91
+ *
92
+ * Returns undefined when there is no memory at all, so a project with nothing to say sends nothing
93
+ * rather than an empty envelope the server has to store.
94
+ */
95
+ function readIntent(reticleRoot) {
96
+ const legacy = readJson(join(reticleRoot, ReticleDir.INTENT_FILE));
97
+ const merged = {
98
+ ...(legacy?.intents ?? {}),
99
+ };
100
+ let shards = 0;
101
+ for (const file of safeReaddir(join(reticleRoot, INTENT_SUBDIR))) {
102
+ if (!file.endsWith(JSON_SUFFIX) || INTENT_INDEX === file)
103
+ continue;
104
+ const shard = readJson(join(reticleRoot, INTENT_SUBDIR, file));
105
+ // A hand-edited or half-written shard costs one subject, never the whole sync.
106
+ if (shard?.intents === undefined)
107
+ continue;
108
+ shards++;
109
+ for (const [id, record] of Object.entries(shard.intents))
110
+ merged[id] = record;
111
+ }
112
+ if (legacy === undefined && 0 === shards)
113
+ return undefined;
114
+ /*
115
+ * Stamp the SUBJECT before sending, on anything that does not already carry one.
116
+ *
117
+ * The subject ladder lives here, in the engine, and knows how to read a flow, a route and a
118
+ * binding predicate. The server had no access to it and had reimplemented a far weaker version —
119
+ * flow-or-nothing — so records this ladder files correctly arrived at the dashboard as
120
+ * `unsorted`. Measured on the real corpus: 105 of 163 records the engine can place were landing
121
+ * in the bucket of last resort, which is what made a coverage map read as one pile with six
122
+ * labels.
123
+ *
124
+ * Computed HERE rather than duplicated there, because two rules for one question is how the
125
+ * answers start disagreeing — and the one with the evidence should be the one that decides.
126
+ * Records that already name a subject keep it: an agent's explicit choice outranks inference.
127
+ */
128
+ const placed = Object.fromEntries(Object.entries(merged).map(([id, record]) => {
129
+ const held = record;
130
+ if ('string' === typeof held.subject && held.subject.length > 0)
131
+ return [id, record];
132
+ const subject = subjectFor({
133
+ surface: held.surface,
134
+ binding: held.binding,
135
+ });
136
+ return [id, { ...held, subject }];
137
+ }));
138
+ return { version: 1, intents: placed };
139
+ }
140
+ /** Directory listing that tolerates absence — an unmigrated project has no `intent/`. */
141
+ function safeReaddir(dir) {
142
+ try {
143
+ return readdirSync(dir);
144
+ }
145
+ catch {
146
+ return [];
147
+ }
148
+ }
149
+ /** What the cycle reads, backed by a real `.reticle` directory. */
150
+ export function diskSource(reticleRoot) {
151
+ return {
152
+ runs: () => readJsonDir(join(reticleRoot, ReticleDir.RUNS_SUBDIR))
153
+ .map((payload) => {
154
+ const id = payload?.runId;
155
+ return 'string' === typeof id ? { runId: id, payload } : undefined;
156
+ })
157
+ // A run artifact with no id cannot be diffed against the server's list, so sending it would
158
+ // mean re-sending it every cycle forever. Dropped rather than uploaded repeatedly.
159
+ .filter((r) => r !== undefined),
160
+ flows: () => readFlows(reticleRoot),
161
+ // Intent is the only derived record that is no longer one file. See readIntent.
162
+ derived: (kind) => 'intent' === kind ? readIntent(reticleRoot) : readJson(join(reticleRoot, DERIVED_FILE[kind])),
163
+ };
164
+ }
165
+ /** What the cycle writes back into `.reticle/`. */
166
+ export function diskSink(reticleRoot) {
167
+ return {
168
+ writeIssues: (issues) => {
169
+ // MERGED, not replaced. A pull returns only what changed since the cursor, so overwriting
170
+ // would drop every earlier decision the moment one new decision arrived.
171
+ const held = readCloudIssues(reticleRoot);
172
+ writeJson(join(reticleRoot, ReticleDir.ISSUES_FILE), {
173
+ triage: { ...held.triage, ...issues.triage },
174
+ });
175
+ },
176
+ writeState: (state) => {
177
+ writeJson(join(reticleRoot, ReticleDir.CLOUD_STATE_FILE), state);
178
+ },
179
+ };
180
+ }
181
+ /** This machine's side of the conversation, or a blank one on first run. */
182
+ export function readCloudState(reticleRoot) {
183
+ const raw = readJson(join(reticleRoot, ReticleDir.CLOUD_STATE_FILE));
184
+ return 'object' === typeof raw && null !== raw ? raw : {};
185
+ }
186
+ /** The decisions pulled back from the dashboard, for the HUD and for the next run to read. */
187
+ export function readCloudIssues(reticleRoot) {
188
+ const raw = readJson(join(reticleRoot, ReticleDir.ISSUES_FILE));
189
+ const triage = 'object' === typeof raw && null !== raw ? raw.triage : undefined;
190
+ return { triage: 'object' === typeof triage && null !== triage ? triage : {} };
191
+ }
@@ -0,0 +1,2 @@
1
+ /** A stable content hash of one derived record. Must equal the server's for the same input. */
2
+ export declare function hashPayload(payload: unknown): string;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The content hash both halves of sync compare on.
3
+ *
4
+ * The server answers "here is a hash per record you have already sent me"; the machine hashes what
5
+ * it holds and sends only what differs. That only works if the two sides compute the SAME number
6
+ * from the same record, so this function is a CONTRACT, not a utility — it has to stay byte-for-byte
7
+ * equivalent to the server's, and it is separated out and tested against fixed vectors for exactly
8
+ * that reason.
9
+ *
10
+ * Keys are sorted before serialising because two structurally identical records must hash the same
11
+ * however they happened to be written out. Without that, a machine whose JSON key order differed
12
+ * from the server's would re-upload the same unchanged record on every cycle, forever, and the whole
13
+ * point of hashing — not paying for what has not moved — would be lost silently.
14
+ */
15
+ import { createHash } from 'node:crypto';
16
+ /** How much of the digest is kept. Long enough that a collision is not a practical concern here. */
17
+ const HASH_LENGTH = 32;
18
+ /** Recursively sort object keys so serialisation is canonical. Arrays keep their order — it is data. */
19
+ function canonical(value) {
20
+ if (Array.isArray(value))
21
+ return value.map(canonical);
22
+ if (value !== null && 'object' === typeof value) {
23
+ return Object.fromEntries(Object.entries(value)
24
+ .sort(([a], [b]) => a.localeCompare(b))
25
+ .map(([k, v]) => [k, canonical(v)]));
26
+ }
27
+ return value;
28
+ }
29
+ /** A stable content hash of one derived record. Must equal the server's for the same input. */
30
+ export function hashPayload(payload) {
31
+ return createHash('sha256')
32
+ .update(JSON.stringify(canonical(payload)))
33
+ .digest('hex')
34
+ .slice(0, HASH_LENGTH);
35
+ }
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { ReticleTool } from '../tools/tool-names.js';
3
3
  import { asNumber, asString } from '../tools/tools-helpers.js';
4
+ import { stepCountSchema, timeoutMsSchema } from '../tools/numeric-bounds.js';
4
5
  import { crawl } from './crawl.js';
5
6
  import { routeFromUrl, routesFromEvents } from '../project/learned-routes.js';
6
7
  const nodeSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -14,9 +15,10 @@ export const CRAWL_TOOLS = [
14
15
  name: ReticleTool.CRAWL,
15
16
  description: 'Autonomously click every reachable interactive control (bounded by maxSteps, default 25) and report anomalies WITHOUT a script. Two classes: single-channel faults (console errors, failed requests ≥400, DEAD controls that dispatched but did nothing) and CONTRADICTIONS — two channels disagreeing about the same click, e.g. the UI advanced while its write failed, a success signal fired over a failed request, a write succeeded and nothing changed, the same write fired twice, or the UI moved on over an in-flight request. Contradictions are the false greens a human cannot see, because a human watches one channel (the screen) and it looks correct. DESTRUCTIVE — it really clicks (may navigate/mutate state); use reticle_explore first for a non-destructive list. Returns { interactiveFound, stepsRun, anomalies[{kind,ref,desc,detail}], counts, visited, truncated }.',
16
17
  inputSchema: {
17
- maxSteps: z.number().optional().describe('Maximum number of controls to click. Default: 25.'),
18
- settleMs: z
19
- .number()
18
+ maxSteps: stepCountSchema
19
+ .optional()
20
+ .describe('Maximum number of controls to click. Default: 25.'),
21
+ settleMs: timeoutMsSchema
20
22
  .optional()
21
23
  .describe('Milliseconds to wait after each click for the app to react. Default: 500.'),
22
24
  scope: z
@@ -13,6 +13,9 @@ export interface CrawlSession {
13
13
  /** Which round of source edits is in force, so a finding drawn entirely from pre-edit evidence
14
14
  * says so. Optional for the same reason `currentDocumentId` is. */
15
15
  readonly currentEditEpoch?: number | undefined;
16
+ /** The page under test, so a third-party beacon cannot be reported against a control. Optional
17
+ * for the same reason `currentDocumentId` is. */
18
+ readonly url?: string | undefined;
16
19
  /**
17
20
  * Attribution window around each click. Optional so a caller can supply a minimal session, but a real
18
21
  * session MUST provide it: without a window the click's own effects carry no actionId, and an
@@ -216,6 +216,11 @@ export async function crawl(session, opts, sleep) {
216
216
  for (const c of findContradictions(events, {
217
217
  currentDocumentId: session.currentDocumentId,
218
218
  currentEditEpoch: session.currentEditEpoch,
219
+ appOrigin: session.url,
220
+ // The crawl's window IS one control's click, so it can name the floor the consequence rules
221
+ // need — without it a crawl would report nothing about the UI moving, which is most of what a
222
+ // crawl is for.
223
+ actionSince: since,
219
224
  })) {
220
225
  counts.contradictions += 1;
221
226
  anomalies.push({
@@ -55,6 +55,49 @@ export declare function resolveDaemonForProject(projectId: string | undefined, h
55
55
  export declare function adoptable(daemonProjectId: string | undefined, callerProjectId: string | undefined): boolean;
56
56
  /** The project a daemon on `port` claims, or undefined when it claims none / is not registered. */
57
57
  export declare function daemonProjectAt(port: number, home: string): string | undefined;
58
+ /**
59
+ * Live daemons OTHER than `myPort` that an app for this project has connected to.
60
+ *
61
+ * The split brain this whole module was written to prevent has a second half nobody was reporting.
62
+ * `resolveMcpPort` correctly refuses to adopt a stranger's daemon and relocates to an OS-assigned
63
+ * port — and the APP, which resolves its own port independently (core's `pickDaemonPort` in the
64
+ * build plugin, falling back to the documented default when nothing matches), can land on the
65
+ * stranger. The pairing token is per USER, not per project, so that connect SUCCEEDS: the app is
66
+ * live on one daemon and the agent is attached to a different, empty one.
67
+ *
68
+ * Every individual check is then green. The browser shows an instrumented page, `reticle status`
69
+ * reports a session, the agent reports Reticle registered and enabled — and the agent's tools see
70
+ * nothing, with a no-session diagnosis that tells it to start a dev server that is already running.
71
+ *
72
+ * `connectedOn` is injected rather than imported so this stays free of the session layer (which
73
+ * imports from here) and so the rule is testable without a real `~/.reticle`. Callers pass
74
+ * `hasProjectConnectedBefore` bound to the same projectId.
75
+ *
76
+ * Only LIVE daemons are named: a port a session used yesterday is history, not a split.
77
+ */
78
+ export declare function daemonsServingProjectElsewhere(projectId: string | undefined, myPort: number, home: string, alive: (pid: number) => boolean, connectedOn: (port: number) => boolean): readonly number[];
79
+ /**
80
+ * The sentence for a split brain, or `undefined` when there is none.
81
+ *
82
+ * Stated as a fact about two ports rather than a hedge, because unlike the sibling-port observation
83
+ * next door this is not "something is listening somewhere" — it is this project's own connection
84
+ * record naming a daemon that is not the one being asked. The wording says which side is empty,
85
+ * since the failure is invisible precisely because both sides look healthy from where they stand.
86
+ */
87
+ export declare function splitBrainNote(myPort: number, elsewhere: readonly number[]): string | undefined;
88
+ /**
89
+ * The other stance on the same split, for a command standing on the port that HAS the app.
90
+ *
91
+ * `splitBrainNote` is asked from the empty daemon and reads the connection record. From the daemon
92
+ * the app is connected to there is nothing missing to notice — sessions are live, every check is
93
+ * green — and the only visible fact is that this project ALSO owns a live daemon somewhere else.
94
+ * One project, two daemons, is a split whichever end you are standing on, and saying it from both
95
+ * ends is the difference between a user inferring it from two green checkmarks and being told.
96
+ *
97
+ * Silent for a caller with no project (it owns no daemon to be split from) and silent when the port
98
+ * being inspected is the project's own.
99
+ */
100
+ export declare function wrongDaemonNote(inspectedPort: number, ownPort: number | undefined): string | undefined;
58
101
  /**
59
102
  * The port this agent's MCP proxy should serve, decided BEFORE the proxy starts.
60
103
  *
@@ -89,6 +89,75 @@ export function adoptable(daemonProjectId, callerProjectId) {
89
89
  export function daemonProjectAt(port, home) {
90
90
  return readDaemonRegistry(home).find((e) => e.port === port)?.projectId;
91
91
  }
92
+ /**
93
+ * Live daemons OTHER than `myPort` that an app for this project has connected to.
94
+ *
95
+ * The split brain this whole module was written to prevent has a second half nobody was reporting.
96
+ * `resolveMcpPort` correctly refuses to adopt a stranger's daemon and relocates to an OS-assigned
97
+ * port — and the APP, which resolves its own port independently (core's `pickDaemonPort` in the
98
+ * build plugin, falling back to the documented default when nothing matches), can land on the
99
+ * stranger. The pairing token is per USER, not per project, so that connect SUCCEEDS: the app is
100
+ * live on one daemon and the agent is attached to a different, empty one.
101
+ *
102
+ * Every individual check is then green. The browser shows an instrumented page, `reticle status`
103
+ * reports a session, the agent reports Reticle registered and enabled — and the agent's tools see
104
+ * nothing, with a no-session diagnosis that tells it to start a dev server that is already running.
105
+ *
106
+ * `connectedOn` is injected rather than imported so this stays free of the session layer (which
107
+ * imports from here) and so the rule is testable without a real `~/.reticle`. Callers pass
108
+ * `hasProjectConnectedBefore` bound to the same projectId.
109
+ *
110
+ * Only LIVE daemons are named: a port a session used yesterday is history, not a split.
111
+ */
112
+ export function daemonsServingProjectElsewhere(projectId, myPort, home, alive, connectedOn) {
113
+ if (projectId === undefined || 0 === projectId.length)
114
+ return [];
115
+ return readDaemonRegistry(home)
116
+ .filter((entry) => entry.port !== myPort && alive(entry.pid) && connectedOn(entry.port))
117
+ .map((entry) => entry.port)
118
+ .sort((a, b) => a - b);
119
+ }
120
+ /**
121
+ * The sentence for a split brain, or `undefined` when there is none.
122
+ *
123
+ * Stated as a fact about two ports rather than a hedge, because unlike the sibling-port observation
124
+ * next door this is not "something is listening somewhere" — it is this project's own connection
125
+ * record naming a daemon that is not the one being asked. The wording says which side is empty,
126
+ * since the failure is invisible precisely because both sides look healthy from where they stand.
127
+ */
128
+ export function splitBrainNote(myPort, elsewhere) {
129
+ if (0 === elsewhere.length)
130
+ return undefined;
131
+ const listed = elsewhere.map((port) => `:${String(port)}`).join(', ');
132
+ const first = elsewhere[0] ?? myPort;
133
+ return (`this project's app is connected to a DIFFERENT Reticle daemon (${listed}), while this one is ` +
134
+ `on :${String(myPort)} — two daemons for one project, and the agent is attached to the empty ` +
135
+ 'half. Nothing is wrong with the app or the wiring. Stop the other daemon with ' +
136
+ `\`reticle stop --port ${String(first)}\` and restart the dev server so the app rediscovers ` +
137
+ 'this one, or start the agent and the dev server from the same project directory so both ' +
138
+ 'resolve the same daemon.');
139
+ }
140
+ /**
141
+ * The other stance on the same split, for a command standing on the port that HAS the app.
142
+ *
143
+ * `splitBrainNote` is asked from the empty daemon and reads the connection record. From the daemon
144
+ * the app is connected to there is nothing missing to notice — sessions are live, every check is
145
+ * green — and the only visible fact is that this project ALSO owns a live daemon somewhere else.
146
+ * One project, two daemons, is a split whichever end you are standing on, and saying it from both
147
+ * ends is the difference between a user inferring it from two green checkmarks and being told.
148
+ *
149
+ * Silent for a caller with no project (it owns no daemon to be split from) and silent when the port
150
+ * being inspected is the project's own.
151
+ */
152
+ export function wrongDaemonNote(inspectedPort, ownPort) {
153
+ if (ownPort === undefined || ownPort === inspectedPort)
154
+ return undefined;
155
+ return (`this is not this project's own daemon: the registry says it owns a live daemon on ` +
156
+ `:${String(ownPort)} and this command is looking at :${String(inspectedPort)}. Two daemons for ` +
157
+ 'one project means the half your agent is attached to and the half your app is connected to can ' +
158
+ 'be different processes, with both reporting healthy. Stop one of them and restart the dev ' +
159
+ 'server so everything lands on the same port.');
160
+ }
92
161
  /**
93
162
  * The port this agent's MCP proxy should serve, decided BEFORE the proxy starts.
94
163
  *
@@ -0,0 +1,15 @@
1
+ import { type DevServerEntry } from '@reticlehq/core';
2
+ /** Injected so the reading is unit-testable without a real home or real processes. */
3
+ export interface DevServerIo {
4
+ readdir: (dir: string) => string[];
5
+ readFile: (path: string) => string;
6
+ isAlive: (pid: number) => boolean;
7
+ }
8
+ /**
9
+ * Every dev server that announced itself and is still running, lowest port first.
10
+ *
11
+ * Fails soft at every step. This runs on the path that explains to a user why setup has not worked,
12
+ * so a half-written file, a hand-edit, or a missing directory must degrade the answer rather than
13
+ * replace the diagnosis with a stack trace.
14
+ */
15
+ export declare function readDevServers(home: string, io?: DevServerIo): DevServerEntry[];
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Reading what dev servers have announced about themselves.
3
+ *
4
+ * The consumer half of `devserver-<port>.json`. A build plugin writes one when its dev server is
5
+ * actually listening; this reads them back, so `init` and `status` can tell three states apart that
6
+ * used to be one:
7
+ *
8
+ * nothing announced no dev server with Reticle loaded is running
9
+ * announced, no page the bundle is wired and no browser dialled — and the entry names the URL
10
+ * announced + session working
11
+ *
12
+ * The middle one is the case that had no message. It was folded into "restart your dev server and
13
+ * load the app", which is two instructions where only the second one applies, addressed to someone
14
+ * who has already done the first.
15
+ *
16
+ * Fs plumbing only — the selection rule is `liveDevServers` in core, shared with anything else that
17
+ * reads this registry, for the same reason `pickDaemonPort` lives there.
18
+ */
19
+ import { readdirSync, readFileSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import { DevServerEntrySchema, devServerRegistryPort, liveDevServers, } from '@reticlehq/core';
22
+ /** process.kill(pid, 0) throws iff the process is gone — the same probe the daemon registry uses. */
23
+ function isAliveNow(pid) {
24
+ try {
25
+ process.kill(pid, 0);
26
+ return true;
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ const nodeIo = {
33
+ readdir: (dir) => readdirSync(dir),
34
+ readFile: (path) => readFileSync(path, 'utf8'),
35
+ isAlive: isAliveNow,
36
+ };
37
+ /**
38
+ * Every dev server that announced itself and is still running, lowest port first.
39
+ *
40
+ * Fails soft at every step. This runs on the path that explains to a user why setup has not worked,
41
+ * so a half-written file, a hand-edit, or a missing directory must degrade the answer rather than
42
+ * replace the diagnosis with a stack trace.
43
+ */
44
+ export function readDevServers(home, io = nodeIo) {
45
+ let files;
46
+ try {
47
+ files = io.readdir(home);
48
+ }
49
+ catch {
50
+ return []; // No state directory yet — nothing has ever run here.
51
+ }
52
+ const entries = [];
53
+ for (const file of files) {
54
+ // Strict on the filename BEFORE reading: the daemon's own `daemon-<port>.json` is a sibling, and
55
+ // parsing one as a dev server would report the daemon itself as an instrumented app.
56
+ if (null === devServerRegistryPort(file))
57
+ continue;
58
+ try {
59
+ const parsed = DevServerEntrySchema.safeParse(JSON.parse(io.readFile(join(home, file))));
60
+ if (parsed.success)
61
+ entries.push(parsed.data);
62
+ }
63
+ catch {
64
+ // Truncated, mid-write, or hand-edited. One bad file is not a reason to report none.
65
+ }
66
+ }
67
+ return liveDevServers(entries, io.isAlive);
68
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * What a woken MCP proxy may do with whatever is sitting on its port.
3
+ *
4
+ * Pulled out as a pure function because the wake path lives inside a long side-effecting closure in
5
+ * `mcp-command.ts`, and the decision it was getting wrong is three lines of reasoning that deserve a
6
+ * test of their own.
7
+ *
8
+ * `resolveMcpPort` asks the identity question at BOOT and relocates rather than adopting a stranger.
9
+ * The wake path asked only whether a daemon answered, so after an idle exit freed the port and
10
+ * another project's daemon bound it, a dormant proxy could reattach to a daemon that was not its
11
+ * own — and then serve an agent verdicts about a different application. `adoptable` is the same rule
12
+ * boot uses; this is just the wake path finally asking it.
13
+ */
14
+ import { PortPresence } from './port-presence.js';
15
+ export declare const WakeAction: {
16
+ /** A daemon we may use is already here. */
17
+ readonly USE: "use";
18
+ /** Nothing is listening: start one. */
19
+ readonly SPAWN: "spawn";
20
+ /** Somebody else's. Go dormant with a reason rather than pretend the wake succeeded. */
21
+ readonly REFUSE: "refuse";
22
+ };
23
+ export type WakeAction = (typeof WakeAction)[keyof typeof WakeAction];
24
+ /**
25
+ * Identity is asked only of a port that actually holds a daemon.
26
+ *
27
+ * A FREE port must stay spawnable whatever the registry says about it, because a stale entry is the
28
+ * registry's ordinary state for the moment after any daemon exits — and refusing there would turn
29
+ * every idle retirement into a permanent lockout, which is the failure this whole path exists to
30
+ * prevent.
31
+ */
32
+ export declare function decideWake(presence: PortPresence, daemonProjectId: string | undefined, callerProjectId: string | undefined): WakeAction;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * What a woken MCP proxy may do with whatever is sitting on its port.
3
+ *
4
+ * Pulled out as a pure function because the wake path lives inside a long side-effecting closure in
5
+ * `mcp-command.ts`, and the decision it was getting wrong is three lines of reasoning that deserve a
6
+ * test of their own.
7
+ *
8
+ * `resolveMcpPort` asks the identity question at BOOT and relocates rather than adopting a stranger.
9
+ * The wake path asked only whether a daemon answered, so after an idle exit freed the port and
10
+ * another project's daemon bound it, a dormant proxy could reattach to a daemon that was not its
11
+ * own — and then serve an agent verdicts about a different application. `adoptable` is the same rule
12
+ * boot uses; this is just the wake path finally asking it.
13
+ */
14
+ import { PortPresence } from './port-presence.js';
15
+ import { adoptable } from './daemon-resolve.js';
16
+ export const WakeAction = {
17
+ /** A daemon we may use is already here. */
18
+ USE: 'use',
19
+ /** Nothing is listening: start one. */
20
+ SPAWN: 'spawn',
21
+ /** Somebody else's. Go dormant with a reason rather than pretend the wake succeeded. */
22
+ REFUSE: 'refuse',
23
+ };
24
+ /**
25
+ * Identity is asked only of a port that actually holds a daemon.
26
+ *
27
+ * A FREE port must stay spawnable whatever the registry says about it, because a stale entry is the
28
+ * registry's ordinary state for the moment after any daemon exits — and refusing there would turn
29
+ * every idle retirement into a permanent lockout, which is the failure this whole path exists to
30
+ * prevent.
31
+ */
32
+ export function decideWake(presence, daemonProjectId, callerProjectId) {
33
+ if (PortPresence.FREE === presence)
34
+ return WakeAction.SPAWN;
35
+ if (PortPresence.DAEMON !== presence)
36
+ return WakeAction.REFUSE;
37
+ return adoptable(daemonProjectId, callerProjectId) ? WakeAction.USE : WakeAction.REFUSE;
38
+ }
@@ -2,6 +2,7 @@ import { z } from 'zod';
2
2
  import { sessionRoot } from '../project/session-root.js';
3
3
  import { asNumber, asString } from '../tools/tools-helpers.js';
4
4
  import { ReticleTool } from '../tools/tool-names.js';
5
+ import { countSchema } from '../tools/numeric-bounds.js';
5
6
  import { readContract } from '../project/reticle-dir.js';
6
7
  import { buildDomainModel } from './domain-model.js';
7
8
  import { proposeInstrumentation } from '../oracles/self-instrument.js';
@@ -19,8 +20,7 @@ export const DOMAIN_TOOLS = [
19
20
  name: ReticleTool.DOMAIN,
20
21
  description: 'Read the app domain model BEFORE testing: every saved flow with its assertion grade, the consequence that MUST hold for it (mustHold = what it actually tests), the anchors/signals it exercises, plus GAPS — declared signals/testids that NO flow asserts (untested intent), and flows that assert no observable consequence. Use this to decide what to test and where the real risk is, instead of crawling the whole app. Reads .reticle/flows/ + .reticle/contract.json (no browser needed).',
21
22
  inputSchema: {
22
- limit: z
23
- .number()
23
+ limit: countSchema
24
24
  .optional()
25
25
  .describe('Most-recent N flows to detail. Defaults to 25 — the per-flow list grows with every saved flow (measured at ~11KB / ~2,700 tokens at 22 flows) and a large project would spend most of an agent context on flows it is not asking about. `flowCount` is always the true total, and the gaps/coverage summary is computed over ALL flows regardless, so capping the detail never changes the analysis.'),
26
26
  },
@@ -138,6 +138,24 @@ export interface ContradictionOptions {
138
138
  * treats absence as current on both sides.
139
139
  */
140
140
  currentEditEpoch?: number | undefined;
141
+ /**
142
+ * The page under test, as the session last recorded it — the app's own origin, in URL form.
143
+ *
144
+ * The first-party/third-party axis. Every rule below asks "did the app disagree with itself", and
145
+ * a failed analytics beacon is not the app: reported independently from several apps, any
146
+ * analytics package installed was enough to grade a correct drive `contradicted`, and on one app
147
+ * EVERY assertion came back that way forever, because it fires a branding call on page load. A
148
+ * verdict field that answers "no" to everything has stopped being a verdict field.
149
+ *
150
+ * Third-party traffic is dropped here rather than reported at a lower severity, for the reason the
151
+ * dev-tooling split is: the rules below would each have to learn to say it. The exclusion is never
152
+ * silent — the URLs ride out in the same disclosure line the toolchain's do — and the calls
153
+ * themselves are untouched in `reticle_network` and the event timeline.
154
+ *
155
+ * Undefined disables the axis, exactly as an undefined `currentDocumentId` disables the document
156
+ * one: a caller that cannot say which page is under test gets the behaviour it had before this.
157
+ */
158
+ appOrigin?: string | undefined;
141
159
  }
142
160
  /**
143
161
  * Cross-channel contradictions in this window, with the edit-epoch caveat attached when it applies.