@reticlehq/server 3.4.0 → 3.6.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/README.md +1 -1
  2. package/SKILL.md +16 -8
  3. package/dist/chromium-launch-options.d.ts +3 -1
  4. package/dist/chromium-launch-options.js +2 -1
  5. package/dist/command/cli/adhoc-verdict.d.ts +1 -0
  6. package/dist/command/cli/adhoc-verdict.js +6 -3
  7. package/dist/command/cli/answers/open-note.d.ts +2 -0
  8. package/dist/command/cli/answers/open-note.js +22 -0
  9. package/dist/command/cli/auth/cloud-session.d.ts +4 -4
  10. package/dist/command/cli/auth/device-flow.d.ts +2 -2
  11. package/dist/command/cli/cli-flow-commands.js +4 -8
  12. package/dist/command/cli/cli-parse.d.ts +1 -1
  13. package/dist/command/cli/cli-parse.js +13 -11
  14. package/dist/command/cli/cli-verify.d.ts +1 -1
  15. package/dist/command/cli/cli-verify.js +35 -25
  16. package/dist/command/cli/cloud-cli.js +110 -12
  17. package/dist/command/cli/cloud-login.d.ts +9 -1
  18. package/dist/command/cli/cloud-login.js +43 -8
  19. package/dist/command/cli/demo/demo-page.js +11 -3
  20. package/dist/command/cli/doctor/browser/chromium-hint.d.ts +46 -0
  21. package/dist/command/cli/doctor/browser/chromium-hint.js +99 -1
  22. package/dist/command/cli/drive/drive-attach.d.ts +27 -2
  23. package/dist/command/cli/drive/drive-attach.js +71 -4
  24. package/dist/command/cli/drive/drive-command.d.ts +2 -1
  25. package/dist/command/cli/drive/drive-command.js +13 -7
  26. package/dist/command/cli/lifecycle/daemon-lifecycle.js +4 -1
  27. package/dist/command/cli/ports/port-holder.d.ts +10 -0
  28. package/dist/command/cli/ports/port-holder.js +24 -0
  29. package/dist/command/cli/setup-mcp-cli.js +15 -5
  30. package/dist/command/cli/tutorial.d.ts +2 -2
  31. package/dist/command/cli/tutorial.js +6 -9
  32. package/dist/command/cli.d.ts +6 -0
  33. package/dist/command/cli.js +45 -23
  34. package/dist/command/daemon/binding/daemon-status-probe.d.ts +2 -0
  35. package/dist/command/daemon/binding/daemon-status-probe.js +1 -0
  36. package/dist/command/daemon/daemon-resolve.d.ts +14 -0
  37. package/dist/command/daemon/daemon-resolve.js +33 -2
  38. package/dist/command/setup/agent-writer.d.ts +1 -1
  39. package/dist/command/setup/agent-writer.js +3 -2
  40. package/dist/command/setup/bringup/connect-lease.d.ts +1 -0
  41. package/dist/command/setup/bringup/connect-lease.js +1 -0
  42. package/dist/command/setup/bringup/dev-server-wait.d.ts +10 -0
  43. package/dist/command/setup/bringup/dev-server-wait.js +20 -1
  44. package/dist/command/setup/init/init-port.d.ts +4 -0
  45. package/dist/command/setup/init/init-port.js +10 -0
  46. package/dist/command/setup/init/init-runtime.d.ts +10 -0
  47. package/dist/command/setup/init/init-runtime.js +71 -9
  48. package/dist/command/setup/node-effects.js +8 -2
  49. package/dist/command/setup/remaining-steps.js +2 -1
  50. package/dist/command/setup/run-setup.d.ts +7 -0
  51. package/dist/command/setup/run-setup.js +38 -25
  52. package/dist/command/setup/setup-command.d.ts +2 -1
  53. package/dist/command/setup/setup-command.js +2 -0
  54. package/dist/command/setup/setup-mcp.d.ts +2 -0
  55. package/dist/command/setup/setup-mcp.js +14 -5
  56. package/dist/command/version/peer-announce.d.ts +6 -2
  57. package/dist/command/version/peer-announce.js +8 -6
  58. package/dist/command/version/version-nudge.d.ts +17 -3
  59. package/dist/command/version/version-nudge.js +25 -4
  60. package/dist/command/version/version-skew.js +11 -2
  61. package/dist/features/crawl/crawl-tools.js +4 -0
  62. package/dist/features/crawl/crawl.d.ts +6 -0
  63. package/dist/features/crawl/crawl.js +9 -1
  64. package/dist/features/exhaust/ledger.d.ts +7 -0
  65. package/dist/features/exhaust/ledger.js +35 -9
  66. package/dist/features/harness/app-findings.d.ts +2 -0
  67. package/dist/features/harness/app-findings.js +52 -0
  68. package/dist/features/harness/drive-report.d.ts +27 -1
  69. package/dist/features/harness/drive-report.js +162 -11
  70. package/dist/features/harness/drivers.d.ts +10 -14
  71. package/dist/features/harness/drivers.js +10 -14
  72. package/dist/features/harness/fill-values.d.ts +3 -69
  73. package/dist/features/harness/fill-values.js +3 -133
  74. package/dist/features/harness/goals.d.ts +21 -0
  75. package/dist/features/harness/goals.js +77 -0
  76. package/dist/features/harness/harness.d.ts +21 -0
  77. package/dist/features/harness/harness.js +69 -2
  78. package/dist/features/harness/platform/drive-target.d.ts +30 -0
  79. package/dist/features/harness/platform/drive-target.js +81 -0
  80. package/dist/features/harness/platform/local-apps.d.ts +94 -0
  81. package/dist/features/harness/platform/local-apps.js +204 -0
  82. package/dist/features/harness/platform/remote-drive.d.ts +180 -0
  83. package/dist/features/harness/platform/remote-drive.js +365 -0
  84. package/dist/features/harness/platform/script.d.ts +48 -0
  85. package/dist/features/harness/platform/script.js +128 -0
  86. package/dist/features/harness/platform/server-driver.d.ts +41 -0
  87. package/dist/features/harness/platform/server-driver.js +179 -0
  88. package/dist/features/harness/platform-config.d.ts +7 -0
  89. package/dist/features/harness/platform-config.js +14 -2
  90. package/dist/features/harness/script/app-controls.d.ts +5 -0
  91. package/dist/features/harness/script/app-controls.js +40 -0
  92. package/dist/features/harness/script/lane-ids.d.ts +14 -0
  93. package/dist/features/harness/script/lane-ids.js +16 -0
  94. package/dist/features/harness/script/script-run.d.ts +41 -0
  95. package/dist/features/harness/script/script-run.js +299 -0
  96. package/dist/features/license/license.d.ts +2 -2
  97. package/dist/features/visual/visual-tools.d.ts +10 -1
  98. package/dist/features/visual/visual-tools.js +40 -3
  99. package/dist/hooks/driven-by.d.ts +4 -0
  100. package/dist/hooks/driven-by.js +16 -0
  101. package/dist/hooks/hook-commands.js +15 -4
  102. package/dist/hooks/tool-log.d.ts +9 -0
  103. package/dist/hooks/tool-log.js +29 -0
  104. package/dist/index.d.ts +1 -0
  105. package/dist/index.js +91 -89
  106. package/dist/judgement/capsule/capsule-store.d.ts +4 -4
  107. package/dist/judgement/runs/artifact/build-verification-run.js +6 -0
  108. package/dist/judgement/runs/artifact/replay-mapping.d.ts +1 -1
  109. package/dist/judgement/runs/artifact/replay-mapping.js +16 -6
  110. package/dist/judgement/runs/artifact/run-store.d.ts +1 -1
  111. package/dist/judgement/runs/artifact/run-store.js +26 -1
  112. package/dist/judgement/runs/drive-run.d.ts +22 -0
  113. package/dist/judgement/runs/drive-run.js +99 -1
  114. package/dist/judgement/runs/repair-prompt.js +1 -1
  115. package/dist/judgement/runs/reticle-runner.js +8 -1
  116. package/dist/judgement/runs/verification-sync.d.ts +3 -2
  117. package/dist/judgement/runs/verification-sync.js +4 -3
  118. package/dist/language/flows/change/gate.d.ts +10 -0
  119. package/dist/language/flows/change/gate.js +22 -0
  120. package/dist/language/flows/decision.js +34 -0
  121. package/dist/language/flows/flow-anchor.d.ts +54 -8
  122. package/dist/language/flows/flow-anchor.js +88 -3
  123. package/dist/language/flows/flow-cross-step.d.ts +15 -0
  124. package/dist/language/flows/flow-cross-step.js +21 -0
  125. package/dist/language/flows/flow-learning.js +8 -11
  126. package/dist/language/flows/flow-mutate-tools.js +5 -3
  127. package/dist/language/flows/flow-replay-run.d.ts +23 -9
  128. package/dist/language/flows/flow-replay-run.js +101 -94
  129. package/dist/language/flows/flow-replay.d.ts +23 -1
  130. package/dist/language/flows/flow-replay.js +92 -19
  131. package/dist/language/flows/flow-scope.js +5 -1
  132. package/dist/language/flows/flow-step-runners.d.ts +20 -2
  133. package/dist/language/flows/flow-step-runners.js +236 -42
  134. package/dist/language/flows/flow-store-for-session.d.ts +2 -2
  135. package/dist/language/flows/flow-store-for-session.js +2 -2
  136. package/dist/language/flows/flow-tools.js +58 -34
  137. package/dist/language/flows/flows.js +5 -2
  138. package/dist/language/flows/heal-run.js +6 -4
  139. package/dist/language/flows/recording/flake.d.ts +2 -2
  140. package/dist/language/flows/recording/tape/recordings.d.ts +30 -4
  141. package/dist/language/flows/recording/tape/recordings.js +40 -8
  142. package/dist/language/flows/replay-from-hud.d.ts +20 -0
  143. package/dist/language/flows/replay-from-hud.js +51 -0
  144. package/dist/language/flows/replay.d.ts +9 -5
  145. package/dist/language/flows/replay.js +27 -9
  146. package/dist/language/flows/suite/suite-flakes.js +2 -0
  147. package/dist/language/flows/suite-selection.d.ts +2 -15
  148. package/dist/language/flows/suite-selection.js +4 -3
  149. package/dist/language/flows/verify-change-tools.js +8 -4
  150. package/dist/launch-chromium.d.ts +27 -5
  151. package/dist/launch-chromium.js +38 -8
  152. package/dist/memory/cloud/cloud-config.d.ts +12 -0
  153. package/dist/memory/cloud/cloud-config.js +15 -0
  154. package/dist/memory/cloud/cloud-sync.d.ts +17 -14
  155. package/dist/memory/cloud/cloud-sync.js +12 -1
  156. package/dist/memory/cloud/harness-config.d.ts +26 -1
  157. package/dist/memory/cloud/harness-config.js +99 -4
  158. package/dist/memory/cloud/hud-notices-source.d.ts +21 -0
  159. package/dist/memory/cloud/hud-notices-source.js +128 -0
  160. package/dist/memory/cloud/memory-scope.d.ts +144 -0
  161. package/dist/memory/cloud/memory-scope.js +205 -0
  162. package/dist/memory/cloud/sync-cycle.d.ts +21 -3
  163. package/dist/memory/cloud/sync-cycle.js +45 -5
  164. package/dist/memory/cloud/sync-daemon.js +7 -5
  165. package/dist/memory/cloud/sync-disk.d.ts +1 -1
  166. package/dist/memory/cloud/sync-disk.js +5 -8
  167. package/dist/memory/impact/account-watch.d.ts +19 -0
  168. package/dist/memory/impact/account-watch.js +34 -0
  169. package/dist/memory/impact/impact-recorder.d.ts +8 -0
  170. package/dist/memory/impact/impact-recorder.js +12 -2
  171. package/dist/memory/impact/impact-store.d.ts +16 -0
  172. package/dist/memory/impact/impact-store.js +142 -26
  173. package/dist/memory/intent/intent-shard.d.ts +2 -2
  174. package/dist/memory/intent/intent-tools.js +88 -5
  175. package/dist/memory/journal/envelope.d.ts +16 -16
  176. package/dist/memory/journal/journal-query.d.ts +14 -0
  177. package/dist/memory/journal/journal-query.js +15 -0
  178. package/dist/memory/journal/journal-recorder.js +7 -1
  179. package/dist/memory/journal/on-disk/retention.d.ts +3 -0
  180. package/dist/memory/journal/on-disk/retention.js +5 -0
  181. package/dist/memory/journal/on-disk/startup-maintenance.js +3 -1
  182. package/dist/memory/journal/on-disk/workspace-tiers.js +7 -4
  183. package/dist/memory/journal/session-end.js +6 -4
  184. package/dist/memory/project/artifact-root-resolver.d.ts +36 -1
  185. package/dist/memory/project/artifact-root-resolver.js +134 -26
  186. package/dist/memory/project/artifact-root.d.ts +42 -5
  187. package/dist/memory/project/artifact-root.js +87 -8
  188. package/dist/memory/project/dir/reticle-dir.d.ts +4 -2
  189. package/dist/memory/project/dir/reticle-dir.js +3 -1
  190. package/dist/memory/project/project-tools.js +22 -3
  191. package/dist/memory/project/session-root.d.ts +24 -1
  192. package/dist/memory/project/session-root.js +41 -5
  193. package/dist/memory/project/sync-status.d.ts +39 -0
  194. package/dist/memory/project/sync-status.js +90 -0
  195. package/dist/memory/recall/memory-tools.js +2 -0
  196. package/dist/memory/recall/project-memory.d.ts +19 -0
  197. package/dist/memory/recall/project-memory.js +45 -6
  198. package/dist/portal/bridge/bridge.d.ts +7 -4
  199. package/dist/portal/bridge/bridge.js +25 -3
  200. package/dist/portal/bridge/pairing-token.d.ts +9 -0
  201. package/dist/portal/bridge/pairing-token.js +17 -0
  202. package/dist/portal/input/real-input.d.ts +81 -10
  203. package/dist/portal/input/real-input.js +207 -22
  204. package/dist/portal/input/viewport-tools.js +19 -9
  205. package/dist/portal/pool/browser-pool.d.ts +22 -9
  206. package/dist/portal/pool/browser-pool.js +70 -35
  207. package/dist/portal/pool/playwright-launcher.d.ts +13 -0
  208. package/dist/portal/pool/playwright-launcher.js +17 -1
  209. package/dist/portal/pool/pool-contract.d.ts +7 -4
  210. package/dist/portal/session/command-payload.d.ts +8 -0
  211. package/dist/portal/session/command-payload.js +19 -0
  212. package/dist/portal/session/dev-server/dev-command.d.ts +12 -0
  213. package/dist/portal/session/dev-server/dev-command.js +61 -12
  214. package/dist/portal/session/facts/departure-note.d.ts +22 -0
  215. package/dist/portal/session/facts/departure-note.js +47 -0
  216. package/dist/portal/session/facts/session-replaced.d.ts +5 -0
  217. package/dist/portal/session/facts/session-replaced.js +5 -0
  218. package/dist/portal/session/first-run-wiring.d.ts +42 -0
  219. package/dist/portal/session/first-run-wiring.js +132 -0
  220. package/dist/portal/session/human/notes-ledger.d.ts +20 -0
  221. package/dist/portal/session/human/notes-ledger.js +55 -0
  222. package/dist/portal/session/human/review-store.d.ts +12 -0
  223. package/dist/portal/session/human/review-store.js +18 -10
  224. package/dist/portal/session/live-control-tools.d.ts +0 -13
  225. package/dist/portal/session/live-control-tools.js +28 -3
  226. package/dist/portal/session/no-session-diagnosis.d.ts +7 -0
  227. package/dist/portal/session/no-session-diagnosis.js +15 -0
  228. package/dist/portal/session/no-session-next-action.d.ts +11 -0
  229. package/dist/portal/session/no-session-next-action.js +31 -5
  230. package/dist/portal/session/no-session-watch.d.ts +14 -1
  231. package/dist/portal/session/no-session-watch.js +63 -10
  232. package/dist/portal/session/presence/session-mirror.js +3 -0
  233. package/dist/portal/session/serving-directory.d.ts +27 -0
  234. package/dist/portal/session/serving-directory.js +45 -0
  235. package/dist/portal/session/session-health.d.ts +7 -0
  236. package/dist/portal/session/session-health.js +6 -0
  237. package/dist/portal/session/session-info.d.ts +8 -1
  238. package/dist/portal/session/session-info.js +6 -0
  239. package/dist/portal/session/session-manager.d.ts +21 -0
  240. package/dist/portal/session/session-manager.js +44 -8
  241. package/dist/portal/session/session-reaper.js +4 -1
  242. package/dist/portal/session/session-successor.d.ts +5 -0
  243. package/dist/portal/session/session-tools.js +26 -3
  244. package/dist/portal/session/session.d.ts +12 -10
  245. package/dist/portal/session/session.js +30 -31
  246. package/dist/start-options.d.ts +6 -0
  247. package/dist/status-payload.d.ts +10 -0
  248. package/dist/status-payload.js +23 -0
  249. package/dist/surface/http-server.d.ts +1 -1
  250. package/dist/surface/http-server.js +15 -4
  251. package/dist/surface/mcp/mcp-proxy.d.ts +5 -0
  252. package/dist/surface/mcp/mcp-proxy.js +36 -5
  253. package/dist/surface/mcp/mcp.d.ts +11 -6
  254. package/dist/surface/mcp/mcp.js +28 -14
  255. package/dist/surface/mcp/proxy/proxy-log.js +3 -5
  256. package/dist/surface/mcp/server-instructions.d.ts +4 -2
  257. package/dist/surface/mcp/server-instructions.js +14 -4
  258. package/dist/surface/mcp/shared-params.js +2 -2
  259. package/dist/surface/tools/act/navigation/navigate-arrival.d.ts +8 -0
  260. package/dist/surface/tools/act/navigation/navigate-arrival.js +56 -2
  261. package/dist/surface/tools/act/settle-in-flight.js +25 -3
  262. package/dist/surface/tools/act-sequence-tool.js +2 -2
  263. package/dist/surface/tools/act-tools.js +25 -9
  264. package/dist/surface/tools/assert/assert-verdict.d.ts +1 -1
  265. package/dist/surface/tools/assert/assert-verdict.js +17 -2
  266. package/dist/surface/tools/browser-tools.js +8 -1
  267. package/dist/surface/tools/chat-drives.d.ts +26 -0
  268. package/dist/surface/tools/chat-drives.js +165 -0
  269. package/dist/surface/tools/dynamic-tools.js +29 -2
  270. package/dist/surface/tools/empty-session-list.d.ts +41 -0
  271. package/dist/surface/tools/empty-session-list.js +80 -0
  272. package/dist/surface/tools/error-recovery.d.ts +6 -0
  273. package/dist/surface/tools/error-recovery.js +38 -6
  274. package/dist/surface/tools/explore-tools.d.ts +10 -1
  275. package/dist/surface/tools/explore-tools.js +82 -13
  276. package/dist/surface/tools/feedback-tools.js +14 -0
  277. package/dist/surface/tools/harness-explore.d.ts +96 -14
  278. package/dist/surface/tools/harness-explore.js +227 -202
  279. package/dist/surface/tools/harness-plan.d.ts +12 -0
  280. package/dist/surface/tools/harness-plan.js +31 -0
  281. package/dist/surface/tools/harness-script.d.ts +8 -0
  282. package/dist/surface/tools/harness-script.js +342 -0
  283. package/dist/surface/tools/harness-toolset.d.ts +14 -1
  284. package/dist/surface/tools/harness-toolset.js +77 -6
  285. package/dist/surface/tools/invoke-tool.js +64 -9
  286. package/dist/surface/tools/lease-seed.d.ts +29 -0
  287. package/dist/surface/tools/lease-seed.js +123 -0
  288. package/dist/surface/tools/lease-tools.d.ts +3 -23
  289. package/dist/surface/tools/lease-tools.js +30 -131
  290. package/dist/surface/tools/live-call-text.js +2 -2
  291. package/dist/surface/tools/next-step.d.ts +19 -0
  292. package/dist/surface/tools/next-step.js +159 -0
  293. package/dist/surface/tools/observe-tools.js +2 -4
  294. package/dist/surface/tools/platform-moment.d.ts +12 -0
  295. package/dist/surface/tools/platform-moment.js +168 -0
  296. package/dist/surface/tools/query-hint-schema.d.ts +7 -0
  297. package/dist/surface/tools/query-hint-schema.js +10 -0
  298. package/dist/surface/tools/read-tools.js +8 -2
  299. package/dist/surface/tools/real-input-attempt.js +96 -13
  300. package/dist/surface/tools/tool-kit.d.ts +25 -1
  301. package/dist/surface/tools/tool-kit.js +4 -6
  302. package/dist/surface/tools/tools.js +20 -48
  303. package/dist/telemetry/argument-shape.js +1 -1
  304. package/dist/telemetry/hud-metrics.d.ts +2 -0
  305. package/dist/telemetry/hud-metrics.js +17 -0
  306. package/dist/telemetry/onboarding-firsts.d.ts +2 -2
  307. package/dist/telemetry/onboarding-firsts.js +2 -2
  308. package/dist/telemetry/telemetry.d.ts +12 -0
  309. package/dist/telemetry/telemetry.js +43 -14
  310. package/dist/telemetry/verification-of.js +1 -0
  311. package/dist/wire-journal.js +4 -1
  312. package/docs/README.md +1 -1
  313. package/docs/actions.mdx +1 -2
  314. package/docs/agent-cheatsheet.md +1 -1
  315. package/docs/autodrive.md +29 -25
  316. package/docs/cli/cloud.mdx +6 -4
  317. package/docs/cli/doctor.mdx +6 -0
  318. package/docs/cli/drive.mdx +8 -2
  319. package/docs/cli/init.mdx +11 -13
  320. package/docs/cli/status.mdx +5 -1
  321. package/docs/cli/version.mdx +3 -3
  322. package/docs/cli.mdx +14 -8
  323. package/docs/desktop.mdx +5 -1
  324. package/docs/environment.mdx +3 -7
  325. package/docs/getting-started.md +14 -2
  326. package/docs/human-control.md +1 -2
  327. package/docs/index.mdx +4 -3
  328. package/docs/install-agentic.mdx +1 -1
  329. package/docs/install-manual.mdx +2 -2
  330. package/docs/packages/next.mdx +3 -0
  331. package/docs/packages/tauri.mdx +2 -2
  332. package/docs/packages/vite-plugin.mdx +24 -1
  333. package/docs/packages.mdx +1 -1
  334. package/docs/quickstart.mdx +23 -11
  335. package/docs/telemetry.md +1 -1
  336. package/docs/tools/act.mdx +1 -1
  337. package/docs/tools/session-and-feedback.mdx +9 -9
  338. package/docs/usage.md +5 -3
  339. package/docs/what-is-recorded.md +13 -3
  340. package/docs/what-is-reticle.mdx +3 -1
  341. package/package.json +8 -12
  342. package/dist/features/harness/driver.d.ts +0 -50
  343. package/dist/features/harness/driver.js +0 -236
  344. package/dist/features/harness/jev-driver.d.ts +0 -188
  345. package/dist/features/harness/jev-driver.js +0 -678
  346. package/dist/features/harness/openai-driver.d.ts +0 -46
  347. package/dist/features/harness/openai-driver.js +0 -194
  348. package/dist/memory/project/dir/fill-value-store.d.ts +0 -32
  349. package/dist/memory/project/dir/fill-value-store.js +0 -88
  350. package/docs/local-registry.md +0 -143
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * The derived records that ride along with runs, and the file each one lives in.
3
3
  *
4
- * A list rather than three hand-written blocks so adding a fourth is one line and cannot be
5
- * half-done — the bundle, the hashing and the reporting all walk this.
4
+ * One list, so adding a record is one line and cannot be half-done: the bundle, the hashing, the
5
+ * reporting and the disk source (`sync-disk.ts`) all read it.
6
6
  */
7
- declare const DERIVED_RECORDS: readonly [{
7
+ export declare const DERIVED_RECORDS: readonly [{
8
8
  readonly kind: "impact";
9
9
  readonly file: "impact.json";
10
10
  }, {
@@ -19,6 +19,12 @@ declare const DERIVED_RECORDS: readonly [{
19
19
  }, {
20
20
  readonly kind: "assertion-tiers";
21
21
  readonly file: "assertion-tiers.json";
22
+ }, {
23
+ readonly kind: "coverage";
24
+ readonly file: "coverage.json";
25
+ }, {
26
+ readonly kind: "notes";
27
+ readonly file: "notes.json";
22
28
  }];
23
29
  type DerivedKind = (typeof DERIVED_RECORDS)[number]['kind'];
24
30
  /** What the machine reads from disk. Injected so a cycle is testable with no filesystem at all. */
@@ -75,6 +81,15 @@ export interface CloudSyncState {
75
81
  * fills the gap the server declared and never overrides the server on a question it answered.
76
82
  */
77
83
  sentRunIds?: string[];
84
+ /**
85
+ * The content hash of each run as the server last ACCEPTED it, for the runs still held locally.
86
+ *
87
+ * A run is not immutable: a session's live drive run is rewritten after every verdict under the
88
+ * same id. Sending by id alone delivered its first version and never the rest, so the server held
89
+ * a tab's first few verdicts and none after. The server upserts a run by id, so a changed run is
90
+ * simply sent again; this is how the machine tells a changed run from one it already delivered.
91
+ */
92
+ sentRunHashes?: Record<string, string>;
78
93
  /**
79
94
  * Runs the server refused, by run id: its reason, and the two things that could change the answer
80
95
  * (a hash of the run file, and of what the platform said it reads). A refused run is not offered
@@ -188,6 +203,9 @@ interface SyncDeps {
188
203
  text: string;
189
204
  }>;
190
205
  }
206
+ /** The server's per-part answer: accepted count and rejection list, each read defensively. */
207
+ /** A 200 that never confirmed the runs it was sent. Kept queued, and said, rather than dropped. */
208
+ export declare const MSG_RUNS_UNCONFIRMED = "sync 200 without a runs result: the server did not confirm the runs it was sent, so they stay queued";
191
209
  /**
192
210
  * Run one cycle. Never throws: the local record is already safe on disk, and a sync that can take
193
211
  * down the thing it is backing up is worse than no sync.
@@ -39,10 +39,10 @@ const SYNC_PULL_PATH = '/v1/sync/pull';
39
39
  /**
40
40
  * The derived records that ride along with runs, and the file each one lives in.
41
41
  *
42
- * A list rather than three hand-written blocks so adding a fourth is one line and cannot be
43
- * half-done — the bundle, the hashing and the reporting all walk this.
42
+ * One list, so adding a record is one line and cannot be half-done: the bundle, the hashing, the
43
+ * reporting and the disk source (`sync-disk.ts`) all read it.
44
44
  */
45
- const DERIVED_RECORDS = [
45
+ export const DERIVED_RECORDS = [
46
46
  { kind: 'impact', file: ReticleDir.IMPACT_FILE },
47
47
  { kind: 'flake', file: ReticleDir.FLAKE_FILE },
48
48
  { kind: 'intent', file: ReticleDir.INTENT_FILE },
@@ -50,6 +50,10 @@ const DERIVED_RECORDS = [
50
50
  // cannot tell a server a page that drifted from one that always behaved that way.
51
51
  { kind: 'envelopes', file: ReticleDir.ENVELOPES_FILE },
52
52
  { kind: 'assertion-tiers', file: ReticleDir.TIERS_FILE },
53
+ // Reticle Coverage: what the platform's Overview shows as controls proved and routes reached.
54
+ { kind: 'coverage', file: ReticleDir.COVERAGE_FILE },
55
+ // The notes a human pinned in the HUD, pending and resolved: the review trail a team reads.
56
+ { kind: 'notes', file: ReticleDir.NOTES_FILE },
53
57
  ];
54
58
  const asJson = (text) => {
55
59
  try {
@@ -75,6 +79,8 @@ const flowName = (flow, index) => {
75
79
  const FLOW_VERSION_FIX = 'update the platform or remove `compare` from the flow';
76
80
  const RUN_VERSION_FIX = 'update the platform';
77
81
  /** The server's per-part answer: accepted count and rejection list, each read defensively. */
82
+ /** A 200 that never confirmed the runs it was sent. Kept queued, and said, rather than dropped. */
83
+ export const MSG_RUNS_UNCONFIRMED = 'sync 200 without a runs result: the server did not confirm the runs it was sent, so they stay queued';
78
84
  function partResult(body, part) {
79
85
  const raw = isRecord(body[part]) ? body[part] : {};
80
86
  const accepted = 'number' === typeof raw['accepted'] ? raw['accepted'] : 0;
@@ -92,6 +98,16 @@ function partResult(body, part) {
92
98
  * Split runs into requests bounded by count and by serialized size. A run bigger than the byte
93
99
  * bound on its own still goes, alone: it cannot be split, and holding it back would hide it.
94
100
  */
101
+ /** The delivery record, kept only for the runs still on disk — the same bound as `sentRunIds`. */
102
+ function heldHashes(runs, recorded) {
103
+ const out = {};
104
+ for (const { runId } of runs) {
105
+ const hash = recorded[runId];
106
+ if (hash !== undefined)
107
+ out[runId] = hash;
108
+ }
109
+ return out;
110
+ }
95
111
  function batchRuns(runs) {
96
112
  const batches = [];
97
113
  let current = [];
@@ -194,8 +210,21 @@ export async function runSyncCycle(deps) {
194
210
  const priorRefusals = isRecord(deps.state.refusedRuns) ? deps.state.refusedRuns : {};
195
211
  const refusedRuns = {};
196
212
  const notRetried = [];
213
+ // Same rule as every record read from disk: only a string can equal a hash we computed.
214
+ const recorded = {};
215
+ if (isRecord(deps.state.sentRunHashes))
216
+ for (const [id, hash] of Object.entries(deps.state.sentRunHashes))
217
+ if ('string' === typeof hash)
218
+ recorded[id] = hash;
219
+ /*
220
+ * Changed means no record, or a record that differs. No record has to count: a machine upgraded
221
+ * onto this can hold runs the server has only in an older version, and a tab that has closed
222
+ * never changes again to earn a re-send. So each such run is
223
+ * sent once and recorded, a one-time cost bounded by what retention keeps.
224
+ */
225
+ const delivered = (run) => known.has(run.runId) && recorded[run.runId] === hashPayload(run.payload);
197
226
  const unsent = allRuns.filter((run) => {
198
- if (known.has(run.runId))
227
+ if (delivered(run))
199
228
  return false;
200
229
  const prior = priorRefusals[run.runId];
201
230
  const unchanged = prior !== undefined &&
@@ -298,6 +327,13 @@ export async function runSyncCycle(deps) {
298
327
  break;
299
328
  }
300
329
  const answer = isRecord(pushed.json) ? pushed.json : {};
330
+ // Runs were sent and the answer says nothing about them: a proxy's 200 page, an empty body.
331
+ // Delivered means the server said so. Recording them as sent here dropped them for good while
332
+ // the cycle reported ok; they stay queued, and the reason is the cycle's error.
333
+ if (batch.length > 0 && !isRecord(answer['runs'])) {
334
+ pushError = MSG_RUNS_UNCONFIRMED;
335
+ break;
336
+ }
301
337
  const runs = partResult(answer, 'runs');
302
338
  runsSent += runs.accepted;
303
339
  // Accepted means the server has it. A rejected run was refused by index, so it is exactly as
@@ -305,8 +341,10 @@ export async function runSyncCycle(deps) {
305
341
  const refusedHere = new Map(runs.rejected.map((r) => [r.index, r.reason]));
306
342
  batch.forEach((run, index) => {
307
343
  const reason = refusedHere.get(index);
308
- if (reason === undefined)
344
+ if (reason === undefined) {
309
345
  known.add(run.runId);
346
+ recorded[run.runId] = hashPayload(run.payload);
347
+ }
310
348
  else
311
349
  refusedRuns[run.runId] = { reason, payloadHash: hashPayload(run.payload), acceptsHash };
312
350
  });
@@ -369,6 +407,7 @@ export async function runSyncCycle(deps) {
369
407
  nextState.refusedSets = refusedSets;
370
408
  if (pushError !== undefined) {
371
409
  nextState.sentRunIds = allRuns.map((r) => r.runId).filter((id) => known.has(id));
410
+ nextState.sentRunHashes = heldHashes(allRuns, recorded);
372
411
  deps.sink.writeState({ ...nextState, lastError: pushError });
373
412
  return {
374
413
  ...empty,
@@ -389,6 +428,7 @@ export async function runSyncCycle(deps) {
389
428
  * will never be attempted — so the record follows the artifacts and needs no number of its own.
390
429
  */
391
430
  nextState.sentRunIds = allRuns.map((r) => r.runId).filter((id) => known.has(id));
431
+ nextState.sentRunHashes = heldHashes(allRuns, recorded);
392
432
  // 3. COLLECT — always, even when there was nothing to send.
393
433
  const query = nextState.cursor === undefined ? '' : `?since=${encodeURIComponent(nextState.cursor)}`;
394
434
  const pull = await call(`${SYNC_PULL_PATH}${query}`);
@@ -21,6 +21,7 @@
21
21
  * laptop on a train would otherwise fill the log with the same line four hundred times and teach
22
22
  * everyone to ignore it.
23
23
  */
24
+ import { syncRequest } from './cloud-sync.js';
24
25
  import { log } from '../../log.js';
25
26
  import { emitSyncHook } from '../../hooks/hook-emit.js';
26
27
  import { describeSync, runSyncCycle } from './sync-cycle.js';
@@ -69,10 +70,7 @@ const movedAnything = (report) => report.runsSent > 0 ||
69
70
  report.capsulesSent > 0 ||
70
71
  report.derivedSent.length > 0 ||
71
72
  report.pulled > 0;
72
- const defaultRequest = async (url, init) => {
73
- const res = await fetch(url, init);
74
- return { status: res.status, text: await res.text() };
75
- };
73
+ const defaultRequest = (url, init) => syncRequest(url, init);
76
74
  /**
77
75
  * Start the loop. Safe to call for an UNLINKED project: it resolves the link on every tick and does
78
76
  * nothing until one appears, which is what lets `reticle link` take effect without a restart.
@@ -147,7 +145,11 @@ export function startSyncDaemon(deps) {
147
145
  continue;
148
146
  try {
149
147
  const cloud = await cloudFor(root);
150
- if (null === cloud.config)
148
+ // Linked HERE, by its own link file. An unlinked root resolves to the environment's key,
149
+ // which is the right answer for the root this daemon stands in and somebody else's project
150
+ // for every other one: a daemon started with RETICLE_API_KEY pushed every project this
151
+ // machine had seen into that one, a status and a pull per root on every cycle.
152
+ if (null === cloud.config || null === cloud.projectId)
151
153
  continue;
152
154
  const report = await pushRoot(root, cloud);
153
155
  if (report === undefined)
@@ -1,4 +1,4 @@
1
- import type { CloudSyncState, PulledIssues, SyncSink, SyncSource } from './sync-cycle.js';
1
+ import { type CloudSyncState, type PulledIssues, type SyncSink, type SyncSource } from './sync-cycle.js';
2
2
  /** What the cycle reads, backed by a real `.reticle` directory. */
3
3
  export declare function diskSource(reticleRoot: string): SyncSource;
4
4
  /** What the cycle writes back into `.reticle/`. */
@@ -10,9 +10,11 @@
10
10
  * one unsynced record, not a crashed sync. There is no state here worth defending against a parse
11
11
  * error — the file will be rewritten on the next tool call anyway.
12
12
  */
13
+ import { shareableRun } from '@reticlehq/core/artifacts';
13
14
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync, } from 'node:fs';
14
15
  import { dirname, join } from 'node:path';
15
16
  import { IntentDir, ReticleDir } from '@reticlehq/core';
17
+ import { DERIVED_RECORDS, } from './sync-cycle.js';
16
18
  import { subjectFor } from '../intent/intent-subject.js';
17
19
  const JSON_SUFFIX = '.json';
18
20
  /** Read + parse, or undefined for absent/unreadable/malformed. Never throws. */
@@ -79,13 +81,7 @@ function readFlows(root) {
79
81
  return [];
80
82
  }
81
83
  }
82
- const DERIVED_FILE = {
83
- impact: ReticleDir.IMPACT_FILE,
84
- flake: ReticleDir.FLAKE_FILE,
85
- intent: ReticleDir.INTENT_FILE,
86
- envelopes: ReticleDir.ENVELOPES_FILE,
87
- 'assertion-tiers': ReticleDir.TIERS_FILE,
88
- };
84
+ const DERIVED_FILE = Object.fromEntries(DERIVED_RECORDS.map((record) => [record.kind, record.file]));
89
85
  /** The directory the sharded intent store writes into, beside the legacy flat file. */
90
86
  const INTENT_SUBDIR = ReticleDir.INTENT_SUBDIR;
91
87
  const INTENT_INDEX = IntentDir.INDEX_FILE;
@@ -170,7 +166,8 @@ export function diskSource(reticleRoot) {
170
166
  runs: () => readJsonDir(join(reticleRoot, ReticleDir.RUNS_SUBDIR))
171
167
  .map((payload) => {
172
168
  const id = payload?.runId;
173
- return 'string' === typeof id ? { runId: id, payload } : undefined;
169
+ // The prompt context leaves the machine only when the project opted in.
170
+ return 'string' === typeof id ? { runId: id, payload: shareableRun(payload) } : undefined;
174
171
  })
175
172
  // A run artifact with no id cannot be diffed against the server's list, so sending it would
176
173
  // mean re-sending it every cycle forever. Dropped rather than uploaded repeatedly.
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Repaint the HUD when sign-in or a project link changes on disk.
3
+ *
4
+ * The impact snapshot already reads both files fresh, but it is only PUSHED when a tab attaches,
5
+ * a tool call lands, or the HUD's own Sign in completes. `reticle logout` and `reticle link` run in
6
+ * another process, so neither caused a push: a signed-out machine kept showing the old name, and a
7
+ * freshly linked project kept saying "Not linked", until somebody reloaded or drove something.
8
+ *
9
+ * ponytail: stat polling (`fs.watchFile`), not `fs.watch`. It works on a file that does not exist
10
+ * yet — the usual state of `cloud.json` before a link — and costs one stat per file per interval.
11
+ */
12
+ /** How often each watched file is stat'ed. A second reads as live to somebody switching windows. */
13
+ export declare const ACCOUNT_WATCH_INTERVAL_MS = 1000;
14
+ export interface AccountFilesWatch {
15
+ /** Start watching one file. Watching the same path twice is a no-op. */
16
+ watch(path: string): void;
17
+ close(): void;
18
+ }
19
+ export declare function watchAccountFiles(onChange: () => void, intervalMs?: number): AccountFilesWatch;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Repaint the HUD when sign-in or a project link changes on disk.
3
+ *
4
+ * The impact snapshot already reads both files fresh, but it is only PUSHED when a tab attaches,
5
+ * a tool call lands, or the HUD's own Sign in completes. `reticle logout` and `reticle link` run in
6
+ * another process, so neither caused a push: a signed-out machine kept showing the old name, and a
7
+ * freshly linked project kept saying "Not linked", until somebody reloaded or drove something.
8
+ *
9
+ * ponytail: stat polling (`fs.watchFile`), not `fs.watch`. It works on a file that does not exist
10
+ * yet — the usual state of `cloud.json` before a link — and costs one stat per file per interval.
11
+ */
12
+ import { unwatchFile, watchFile } from 'node:fs';
13
+ /** How often each watched file is stat'ed. A second reads as live to somebody switching windows. */
14
+ export const ACCOUNT_WATCH_INTERVAL_MS = 1_000;
15
+ export function watchAccountFiles(onChange, intervalMs = ACCOUNT_WATCH_INTERVAL_MS) {
16
+ const watched = new Set();
17
+ const listener = () => {
18
+ onChange();
19
+ };
20
+ return {
21
+ watch(path) {
22
+ if (watched.has(path))
23
+ return;
24
+ watched.add(path);
25
+ // Not persistent: a watcher must never be the reason a daemon cannot exit.
26
+ watchFile(path, { persistent: false, interval: intervalMs }, listener);
27
+ },
28
+ close() {
29
+ for (const path of watched)
30
+ unwatchFile(path, listener);
31
+ watched.clear();
32
+ },
33
+ };
34
+ }
@@ -16,9 +16,17 @@ export declare function initImpact(opts: {
16
16
  * directory may not reach for it. The daemon owns both sides.
17
17
  */
18
18
  config?: ConfigSource;
19
+ /** Resolve credentials per project when one daemon serves multiple linked apps. */
20
+ configForRoot?: (root: string) => ConfigSource;
21
+ /** Reticle Coverage for a project root, supplied by the daemon (the ledger is a feature's). */
22
+ coverageForRoot?: (root: string) => () => Record<string, number> | undefined;
19
23
  reticleRoot: string | undefined;
20
24
  projectName?: string;
21
25
  now?: () => number;
26
+ /** Where the machine-wide `~/.reticle` lives. Tests pass a temp dir so they never write the real one. */
27
+ globalRoot?: string;
28
+ /** This build's version, for HUD notices that need a newer SDK. */
29
+ sdkVersion?: string;
22
30
  }): ImpactStore | undefined;
23
31
  /** The live store for a root (default when omitted), if one has been initialised. */
24
32
  export declare function impactStore(root?: string): ImpactStore | undefined;
@@ -31,7 +31,13 @@ function storeFor(root) {
31
31
  return undefined;
32
32
  let found = stores.get(key);
33
33
  if (found === undefined) {
34
- found = new ImpactStore({ ...storeOpts, reticleRoot: key });
34
+ const { configForRoot, coverageForRoot, ...opts } = storeOpts;
35
+ found = new ImpactStore({
36
+ ...opts,
37
+ ...(configForRoot === undefined ? {} : { config: configForRoot(key) }),
38
+ ...(coverageForRoot === undefined ? {} : { coverage: coverageForRoot(key) }),
39
+ reticleRoot: key,
40
+ });
35
41
  stores.set(key, found);
36
42
  }
37
43
  return found;
@@ -50,11 +56,15 @@ export function initImpact(opts) {
50
56
  return storeFor(undefined);
51
57
  if (defaultRoot === undefined) {
52
58
  defaultRoot = opts.reticleRoot;
53
- const { projectName, now, config } = opts;
59
+ const { projectName, now, config, configForRoot, coverageForRoot, globalRoot, sdkVersion } = opts;
54
60
  storeOpts = {
55
61
  ...(projectName === undefined ? {} : { projectName }),
56
62
  ...(now === undefined ? {} : { now }),
63
+ ...(globalRoot === undefined ? {} : { globalRoot }),
64
+ ...(sdkVersion === undefined ? {} : { sdkVersion }),
57
65
  ...(config === undefined ? {} : { config }),
66
+ ...(configForRoot === undefined ? {} : { configForRoot }),
67
+ ...(coverageForRoot === undefined ? {} : { coverageForRoot }),
58
68
  };
59
69
  }
60
70
  return storeFor(opts.reticleRoot);
@@ -1,5 +1,6 @@
1
1
  import { type OfferSource } from '../cloud/harness-offer.js';
2
2
  import type { ConfigSource } from '../cloud/harness-config.js';
3
+ import { type NoticesSource } from '../cloud/hud-notices-source.js';
3
4
  import { type ImpactCounts, type ImpactDefect, type AccountState, type ImpactScope, type ImpactSnapshot } from '@reticlehq/core';
4
5
  /** Read a scope, tolerating absence and corruption: a broken file starts over rather than throwing. */
5
6
  export declare function readScope(path: string, now: number): ImpactScope;
@@ -23,6 +24,15 @@ export interface ImpactFoldMeta {
23
24
  }
24
25
  /** Fold one delta into a scope: totals, today's bucket, records and streak. Pure. */
25
26
  export declare function applyDelta(scope: ImpactScope, delta: Partial<ImpactCounts>, now: number, meta?: ImpactFoldMeta): ImpactScope;
27
+ /**
28
+ * All time, never smaller than the project being looked at.
29
+ *
30
+ * The two scopes live in different files: the project's in its `.reticle`, the machine's in the
31
+ * home directory. A project whose history began before this machine's ledger (an upgrade, a new
32
+ * laptop, a moved checkout) showed more under "This project" than under "All time", which no reader
33
+ * can believe. The machine has done at least what any one of its projects has.
34
+ */
35
+ export declare function atLeastProject(global: ImpactScope, project: ImpactScope): ImpactScope;
26
36
  /**
27
37
  * The live impact record for one project.
28
38
  *
@@ -42,6 +52,12 @@ export declare class ImpactStore {
42
52
  /** Where the workspace stands with the free harness offer. Injected so no test touches a network. */
43
53
  offer?: OfferSource;
44
54
  config?: ConfigSource;
55
+ /** The HUD rail's notices. Defaults to the daemon's cached copy of the published file. */
56
+ notices?: NoticesSource;
57
+ /** This build's version, for notices that need a newer SDK. Passed in: the store reads no package. */
58
+ sdkVersion?: string;
59
+ /** Reticle Coverage per level, as percentages. Injected: the ledger lives in `features/exhaust`. */
60
+ coverage?: () => Record<string, number> | undefined;
45
61
  });
46
62
  /** Notified after every fold, so the HUD can be pushed a fresh snapshot. */
47
63
  onChange(fn: () => void): void;
@@ -1,23 +1,25 @@
1
1
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { describeSync, overallStatus, readSyncSummary } from '../project/sync-status.js';
2
3
  import { dirname, join } from 'node:path';
3
4
  import { homedir } from 'node:os';
4
5
  import { readAccountState } from '../cloud/account-state.js';
5
6
  import { harnessOfferSource } from '../cloud/harness-offer.js';
7
+ import { hudNoticesSource } from '../cloud/hud-notices-source.js';
8
+ import { selectNotices } from '@reticlehq/core/hud';
6
9
  import { ReticleDir, IMPACT_DAILY_BUCKETS, IMPACT_DEFECT_LIMIT, IMPACT_SCHEMA_VERSION, ImpactScopeSchema, addImpactCounts, emptyImpactCounts, emptyImpactRecords, estimateImpactSavings, } from '@reticlehq/core';
7
10
  /**
8
11
  * Where Reticle keeps the record of what it has done for you.
9
12
  *
10
- * Two scopes, two files, both local and never uploaded: the project's own `.reticle/impact.json`,
11
- * and a machine-wide `~/.reticle/impact.json` that answers "what has Reticle done for me overall"
12
- * across every app you have instrumented.
13
+ * Two scopes, two files: the project's own `.reticle/impact.json`, and a machine-wide
14
+ * `~/.reticle/impact.json` that answers "what has Reticle done for me overall" across every app you
15
+ * have instrumented.
13
16
  *
14
- * This is NOT telemetry. Telemetry answers our questions about the product and leaves the machine;
15
- * this answers the user's question about their own work and never does.
17
+ * This is NOT telemetry. Telemetry answers our questions about the product; this answers the user's
18
+ * question about their own work. A linked project's record syncs to that user's own dashboard (the
19
+ * sync cycle's `impact` kind); the machine-wide record never leaves the machine.
16
20
  */
17
21
  /** Writes are debounced: a verification loop is 50-200 calls, and each one is a counter bump. */
18
22
  const WRITE_DEBOUNCE_MS = 800;
19
- /** The project's cloud binding, written by `reticle link`. Absent for an unlinked project. */
20
- const CLOUD_LINK_FILE = 'cloud.json';
21
23
  /**
22
24
  * `reticleRoot` is the project's own `.reticle` directory (what every other store here is handed);
23
25
  * the global scope lives beside the daemon's own state in `~/.reticle`.
@@ -42,7 +44,7 @@ function impactPaths(reticleRoot, globalRoot) {
42
44
  */
43
45
  function readDashboardUrl(reticleRoot) {
44
46
  try {
45
- const raw = JSON.parse(readFileSync(join(reticleRoot, CLOUD_LINK_FILE), 'utf8'));
47
+ const raw = JSON.parse(readFileSync(join(reticleRoot, ReticleDir.CLOUD_LINK_FILE), 'utf8'));
46
48
  if ('object' !== typeof raw || null === raw)
47
49
  return undefined;
48
50
  const value = raw['dashboardUrl'];
@@ -92,6 +94,12 @@ function writeScope(path, scope) {
92
94
  // memory and the next write attempt carries them.
93
95
  }
94
96
  }
97
+ /** The daemon's cached copy of the HUD notices file, beside the machine-wide impact record. */
98
+ const HUD_NOTICES_CACHE_FILE = 'hud-notices.json';
99
+ /** How long a sync status is reused before it is read again. */
100
+ const SYNC_STATUS_EVERY_MS = 10_000;
101
+ /** When the caller did not say which build this is: matches only notices with no `minSdk`. */
102
+ const UNKNOWN_SDK_VERSION = '0.0.0';
95
103
  /** YYYY-MM-DD in local time - the day boundary a person recognises, not UTC's. */
96
104
  export function isoDay(now) {
97
105
  const d = new Date(now);
@@ -105,33 +113,55 @@ function isNextDay(a, b) {
105
113
  const next = new Date(`${b}T00:00:00`);
106
114
  return 1 === Math.round((next.getTime() - prev.getTime()) / 86_400_000);
107
115
  }
116
+ /**
117
+ * Consecutive days ending at the newest recorded day, read from the days themselves.
118
+ *
119
+ * It was a counter bumped when a fold opened a new day and compared only with the bucket before it,
120
+ * so one stray day in between (a late flush from another daemon, a writer with a frozen clock) reset
121
+ * it to 1. Counting back from the newest date means no out-of-order write can shorten it.
122
+ */
123
+ function streakEndingAtLatest(sortedDates) {
124
+ let streak = 0;
125
+ let later;
126
+ for (let i = sortedDates.length - 1; i >= 0; i -= 1) {
127
+ const date = sortedDates[i];
128
+ if (date === undefined)
129
+ break;
130
+ if (later !== undefined && !isNextDay(date, later))
131
+ break;
132
+ streak += 1;
133
+ later = date;
134
+ }
135
+ return streak;
136
+ }
108
137
  /** Fold one delta into a scope: totals, today's bucket, records and streak. Pure. */
109
138
  export function applyDelta(scope, delta, now, meta = {}) {
110
139
  const today = isoDay(now);
111
140
  const counts = addImpactCounts(scope.counts, delta);
112
- const days = scope.days.slice();
113
- const last = days[days.length - 1];
114
- if (last !== undefined && last.date === today) {
115
- days[days.length - 1] = { date: today, counts: addImpactCounts(last.counts, delta) };
116
- }
117
- else {
118
- days.push({ date: today, counts: addImpactCounts(emptyImpactCounts(), delta) });
141
+ // One bucket per date, wherever the delta's date falls. The machine-wide record has many writers
142
+ // and a late flush carries an older date, so "the last bucket is today" is not something a fold
143
+ // may assume — appending on that assumption duplicated dates and put them out of order.
144
+ const byDate = new Map();
145
+ for (const day of scope.days) {
146
+ byDate.set(day.date, addImpactCounts(byDate.get(day.date) ?? emptyImpactCounts(), day.counts));
119
147
  }
148
+ const todayCounts = addImpactCounts(byDate.get(today) ?? emptyImpactCounts(), delta);
149
+ byDate.set(today, todayCounts);
150
+ const dates = [...byDate.keys()].sort();
151
+ const days = dates.map((date) => ({ date, counts: byDate.get(date) ?? emptyImpactCounts() }));
120
152
  while (days.length > IMPACT_DAILY_BUCKETS)
121
153
  days.shift();
122
- const todayCounts = days[days.length - 1]?.counts ?? emptyImpactCounts();
123
- const previousDay = days[days.length - 2];
124
154
  const records = { ...scope.records };
125
155
  records.bestVerdictDay = Math.max(records.bestVerdictDay, todayCounts.verdicts);
126
156
  records.bestDefectDay = Math.max(records.bestDefectDay, todayCounts.failed);
127
- if (last === undefined || last.date !== today) {
128
- // A new day joins the streak only if it follows yesterday; otherwise it starts a new one.
129
- records.streakDays =
130
- previousDay !== undefined && isNextDay(previousDay.date, today) ? records.streakDays + 1 : 1;
131
- }
132
- else if (0 === records.streakDays) {
133
- records.streakDays = 1;
134
- }
157
+ // The window keeps IMPACT_DAILY_BUCKETS days, so a streak longer than it cannot be read off the
158
+ // dates alone: when the whole window is one unbroken run, it continues the streak already held.
159
+ const inWindow = streakEndingAtLatest(dates);
160
+ const unbroken = inWindow === dates.length && scope.days.length >= IMPACT_DAILY_BUCKETS;
161
+ const newDay = !scope.days.some((day) => day.date === today);
162
+ records.streakDays = unbroken
163
+ ? Math.max(inWindow, scope.records.streakDays + (newDay ? 1 : 0))
164
+ : inWindow;
135
165
  records.bestStreakDays = Math.max(records.bestStreakDays, records.streakDays);
136
166
  records.longestRunMs = Math.max(records.longestRunMs, meta.runMs ?? 0);
137
167
  /*
@@ -150,6 +180,41 @@ export function applyDelta(scope, delta, now, meta = {}) {
150
180
  defects,
151
181
  };
152
182
  }
183
+ /** The larger of two counters, key by key. */
184
+ function maxEach(a, b) {
185
+ const out = { ...a };
186
+ for (const key of Object.keys(b))
187
+ out[key] = Math.max(a[key] ?? 0, b[key] ?? 0);
188
+ return out;
189
+ }
190
+ /**
191
+ * All time, never smaller than the project being looked at.
192
+ *
193
+ * The two scopes live in different files: the project's in its `.reticle`, the machine's in the
194
+ * home directory. A project whose history began before this machine's ledger (an upgrade, a new
195
+ * laptop, a moved checkout) showed more under "This project" than under "All time", which no reader
196
+ * can believe. The machine has done at least what any one of its projects has.
197
+ */
198
+ export function atLeastProject(global, project) {
199
+ // The earlier of the two starts; zero is "never recorded", not the epoch.
200
+ const starts = [global.since, project.since].filter((t) => 0 < t);
201
+ return {
202
+ ...global,
203
+ counts: maxEach(global.counts, project.counts),
204
+ records: maxEach(global.records, project.records),
205
+ savings: {
206
+ tokens: {
207
+ ...global.savings.tokens,
208
+ value: Math.max(global.savings.tokens.value, project.savings.tokens.value),
209
+ },
210
+ minutes: {
211
+ ...global.savings.minutes,
212
+ value: Math.max(global.savings.minutes.value, project.savings.minutes.value),
213
+ },
214
+ },
215
+ since: 0 === starts.length ? 0 : Math.min(...starts),
216
+ };
217
+ }
153
218
  /**
154
219
  * The live impact record for one project.
155
220
  *
@@ -158,6 +223,9 @@ export function applyDelta(scope, delta, now, meta = {}) {
158
223
  */
159
224
  export class ImpactStore {
160
225
  #paths;
226
+ #root;
227
+ /** The sync status, re-read at most every SYNC_STATUS_EVERY_MS: it reads every run file. */
228
+ #sync;
161
229
  #now;
162
230
  #projectName;
163
231
  #dashboardUrl;
@@ -178,7 +246,11 @@ export class ImpactStore {
178
246
  /** Where this workspace stands with the free harness offer. Cached; see harness-offer.ts. */
179
247
  #offer;
180
248
  #config;
249
+ #notices;
250
+ #sdkVersion;
251
+ #coverage;
181
252
  constructor(opts) {
253
+ this.#coverage = opts.coverage;
182
254
  this.#paths = impactPaths(opts.reticleRoot, opts.globalRoot ?? homedir());
183
255
  // Resolved per snapshot, not cached: a user who runs `reticle login` in another terminal must
184
256
  // see the HUD change without restarting the daemon that is watching their app.
@@ -186,9 +258,16 @@ export class ImpactStore {
186
258
  this.#now = opts.now ?? (() => Date.now());
187
259
  this.#projectName = opts.projectName;
188
260
  this.#dashboardUrl = readDashboardUrl(opts.reticleRoot);
261
+ this.#root = opts.reticleRoot;
189
262
  // The claim happens in the console, so the link this project was linked to IS the claim link —
190
263
  // built here rather than in the HUD, which has no way to know where this project points.
191
264
  this.#offer = opts.offer ?? harnessOfferSource(process.env, () => this.#dashboardUrl);
265
+ this.#notices =
266
+ opts.notices ??
267
+ hudNoticesSource({
268
+ cacheFile: join(opts.globalRoot ?? homedir(), ReticleDir.ROOT, HUD_NOTICES_CACHE_FILE),
269
+ });
270
+ this.#sdkVersion = opts.sdkVersion ?? UNKNOWN_SDK_VERSION;
192
271
  // No default: the loader lives in `features/harness` and this file may not reach for it.
193
272
  // A store built without one simply reports no harness config, which the HUD renders as no row.
194
273
  this.#config = opts.config ?? { read: () => undefined };
@@ -196,6 +275,23 @@ export class ImpactStore {
196
275
  this.#project = readScope(this.#paths.project, now);
197
276
  this.#global = readScope(this.#paths.global, now);
198
277
  }
278
+ #syncStatus() {
279
+ const now = this.#now();
280
+ if (this.#sync !== undefined && now - this.#sync.at < SYNC_STATUS_EVERY_MS)
281
+ return this.#sync.value;
282
+ const summary = readSyncSummary(this.#root, true);
283
+ const value = {
284
+ status: overallStatus(summary),
285
+ runs: summary.runs,
286
+ onPlatform: summary.onPlatform,
287
+ pending: summary.pending,
288
+ refused: summary.refused.length + summary.refusedMore,
289
+ ...(summary.lastPushAt === undefined ? {} : { lastPushAt: summary.lastPushAt }),
290
+ said: describeSync(summary, now),
291
+ };
292
+ this.#sync = { at: now, value };
293
+ return value;
294
+ }
199
295
  /** Notified after every fold, so the HUD can be pushed a fresh snapshot. */
200
296
  onChange(fn) {
201
297
  this.#onChange = fn;
@@ -214,10 +310,13 @@ export class ImpactStore {
214
310
  const snap = {
215
311
  schemaVersion: IMPACT_SCHEMA_VERSION,
216
312
  project: this.#project,
217
- global: this.#global,
313
+ global: atLeastProject(this.#global, this.#project),
218
314
  };
219
315
  if (this.#projectName !== undefined)
220
316
  snap.projectName = this.#projectName;
317
+ // Re-read, like the account: `reticle link` runs in another terminal while this store lives as
318
+ // long as the daemon, so a construction-time read said "Not linked" until a restart.
319
+ this.#dashboardUrl = readDashboardUrl(this.#root);
221
320
  if (this.#dashboardUrl !== undefined)
222
321
  snap.dashboardUrl = this.#dashboardUrl;
223
322
  snap.account = this.#account();
@@ -236,6 +335,23 @@ export class ImpactStore {
236
335
  const cfg = this.#config.read();
237
336
  if (cfg !== undefined)
238
337
  snap.harnessConfig = cfg;
338
+ // Chosen here, where the account and the entitlement are both known. Absent when nothing applies,
339
+ // so the HUD falls back to the slides bundled with the SDK.
340
+ const notices = selectNotices(this.#notices.read(), {
341
+ signedIn: snap.account?.signedIn,
342
+ entitled: cfg?.harnessEntitled,
343
+ sdkVersion: this.#sdkVersion,
344
+ now: this.#now(),
345
+ });
346
+ if (0 < notices.length)
347
+ snap.notices = notices;
348
+ const coverage = this.#coverage?.();
349
+ if (coverage !== undefined)
350
+ snap.coverage = coverage;
351
+ // Only for a linked project: an unlinked one has nothing on the platform, and the HUD already
352
+ // offers the way to link it.
353
+ if (this.#dashboardUrl !== undefined)
354
+ snap.sync = this.#syncStatus();
239
355
  return snap;
240
356
  }
241
357
  /** Write now (process exit, tests). */