@reticlehq/server 3.5.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 (328) 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/cli-flow-commands.js +4 -8
  10. package/dist/command/cli/cli-parse.d.ts +1 -1
  11. package/dist/command/cli/cli-parse.js +10 -10
  12. package/dist/command/cli/cli-verify.d.ts +1 -1
  13. package/dist/command/cli/cli-verify.js +35 -25
  14. package/dist/command/cli/cloud-cli.js +51 -12
  15. package/dist/command/cli/cloud-login.d.ts +8 -0
  16. package/dist/command/cli/cloud-login.js +36 -2
  17. package/dist/command/cli/demo/demo-page.js +11 -3
  18. package/dist/command/cli/doctor/browser/chromium-hint.d.ts +46 -0
  19. package/dist/command/cli/doctor/browser/chromium-hint.js +99 -1
  20. package/dist/command/cli/drive/drive-attach.d.ts +27 -2
  21. package/dist/command/cli/drive/drive-attach.js +71 -4
  22. package/dist/command/cli/drive/drive-command.d.ts +2 -1
  23. package/dist/command/cli/drive/drive-command.js +13 -7
  24. package/dist/command/cli/lifecycle/daemon-lifecycle.js +4 -1
  25. package/dist/command/cli/ports/port-holder.d.ts +10 -0
  26. package/dist/command/cli/ports/port-holder.js +24 -0
  27. package/dist/command/cli/setup-mcp-cli.js +6 -4
  28. package/dist/command/cli/tutorial.d.ts +2 -2
  29. package/dist/command/cli/tutorial.js +6 -9
  30. package/dist/command/cli.d.ts +6 -0
  31. package/dist/command/cli.js +28 -22
  32. package/dist/command/daemon/binding/daemon-status-probe.d.ts +2 -0
  33. package/dist/command/daemon/binding/daemon-status-probe.js +1 -0
  34. package/dist/command/daemon/daemon-resolve.d.ts +14 -0
  35. package/dist/command/daemon/daemon-resolve.js +33 -2
  36. package/dist/command/setup/agent-writer.d.ts +1 -1
  37. package/dist/command/setup/agent-writer.js +3 -2
  38. package/dist/command/setup/bringup/connect-lease.d.ts +1 -0
  39. package/dist/command/setup/bringup/connect-lease.js +1 -0
  40. package/dist/command/setup/bringup/dev-server-wait.d.ts +8 -0
  41. package/dist/command/setup/bringup/dev-server-wait.js +19 -0
  42. package/dist/command/setup/init/init-port.d.ts +4 -0
  43. package/dist/command/setup/init/init-port.js +10 -0
  44. package/dist/command/setup/init/init-runtime.d.ts +10 -0
  45. package/dist/command/setup/init/init-runtime.js +18 -1
  46. package/dist/command/setup/node-effects.js +2 -1
  47. package/dist/command/setup/remaining-steps.js +2 -1
  48. package/dist/command/setup/run-setup.d.ts +5 -0
  49. package/dist/command/setup/run-setup.js +35 -27
  50. package/dist/command/setup/setup-command.d.ts +2 -1
  51. package/dist/command/setup/setup-command.js +2 -0
  52. package/dist/command/version/version-skew.js +11 -2
  53. package/dist/features/crawl/crawl-tools.js +4 -0
  54. package/dist/features/crawl/crawl.d.ts +6 -0
  55. package/dist/features/crawl/crawl.js +9 -1
  56. package/dist/features/exhaust/ledger.d.ts +7 -0
  57. package/dist/features/exhaust/ledger.js +35 -9
  58. package/dist/features/harness/app-findings.d.ts +2 -0
  59. package/dist/features/harness/app-findings.js +52 -0
  60. package/dist/features/harness/drive-report.d.ts +27 -1
  61. package/dist/features/harness/drive-report.js +162 -11
  62. package/dist/features/harness/drivers.d.ts +10 -14
  63. package/dist/features/harness/drivers.js +10 -14
  64. package/dist/features/harness/fill-values.d.ts +3 -73
  65. package/dist/features/harness/fill-values.js +3 -169
  66. package/dist/features/harness/goals.d.ts +21 -0
  67. package/dist/features/harness/goals.js +77 -0
  68. package/dist/features/harness/harness.d.ts +21 -0
  69. package/dist/features/harness/harness.js +69 -2
  70. package/dist/features/harness/platform/drive-target.d.ts +30 -0
  71. package/dist/features/harness/platform/drive-target.js +81 -0
  72. package/dist/features/harness/platform/local-apps.d.ts +94 -0
  73. package/dist/features/harness/platform/local-apps.js +204 -0
  74. package/dist/features/harness/platform/remote-drive.d.ts +180 -0
  75. package/dist/features/harness/platform/remote-drive.js +365 -0
  76. package/dist/features/harness/platform/script.d.ts +48 -0
  77. package/dist/features/harness/platform/script.js +128 -0
  78. package/dist/features/harness/platform/server-driver.d.ts +41 -0
  79. package/dist/features/harness/platform/server-driver.js +179 -0
  80. package/dist/features/harness/platform-config.d.ts +7 -0
  81. package/dist/features/harness/platform-config.js +14 -2
  82. package/dist/features/harness/script/app-controls.d.ts +5 -0
  83. package/dist/features/harness/script/app-controls.js +40 -0
  84. package/dist/features/harness/script/lane-ids.d.ts +14 -0
  85. package/dist/features/harness/script/lane-ids.js +16 -0
  86. package/dist/features/harness/script/script-run.d.ts +41 -0
  87. package/dist/features/harness/script/script-run.js +299 -0
  88. package/dist/features/license/license.d.ts +2 -2
  89. package/dist/features/visual/visual-tools.d.ts +10 -1
  90. package/dist/features/visual/visual-tools.js +40 -3
  91. package/dist/hooks/driven-by.d.ts +4 -0
  92. package/dist/hooks/driven-by.js +16 -0
  93. package/dist/hooks/hook-commands.js +15 -4
  94. package/dist/hooks/tool-log.d.ts +9 -0
  95. package/dist/hooks/tool-log.js +29 -0
  96. package/dist/index.d.ts +1 -0
  97. package/dist/index.js +76 -82
  98. package/dist/judgement/runs/artifact/build-verification-run.js +6 -0
  99. package/dist/judgement/runs/artifact/replay-mapping.d.ts +1 -1
  100. package/dist/judgement/runs/artifact/replay-mapping.js +16 -6
  101. package/dist/judgement/runs/artifact/run-store.d.ts +1 -1
  102. package/dist/judgement/runs/artifact/run-store.js +26 -1
  103. package/dist/judgement/runs/drive-run.d.ts +22 -0
  104. package/dist/judgement/runs/drive-run.js +99 -1
  105. package/dist/judgement/runs/repair-prompt.js +1 -1
  106. package/dist/judgement/runs/reticle-runner.js +8 -1
  107. package/dist/judgement/runs/verification-sync.d.ts +3 -2
  108. package/dist/judgement/runs/verification-sync.js +4 -3
  109. package/dist/language/flows/change/gate.d.ts +10 -0
  110. package/dist/language/flows/change/gate.js +22 -0
  111. package/dist/language/flows/decision.js +34 -0
  112. package/dist/language/flows/flow-anchor.d.ts +54 -8
  113. package/dist/language/flows/flow-anchor.js +88 -3
  114. package/dist/language/flows/flow-cross-step.d.ts +15 -0
  115. package/dist/language/flows/flow-cross-step.js +21 -0
  116. package/dist/language/flows/flow-learning.js +8 -11
  117. package/dist/language/flows/flow-mutate-tools.js +5 -3
  118. package/dist/language/flows/flow-replay-run.d.ts +23 -9
  119. package/dist/language/flows/flow-replay-run.js +101 -94
  120. package/dist/language/flows/flow-replay.d.ts +23 -1
  121. package/dist/language/flows/flow-replay.js +92 -19
  122. package/dist/language/flows/flow-scope.js +5 -1
  123. package/dist/language/flows/flow-step-runners.d.ts +20 -2
  124. package/dist/language/flows/flow-step-runners.js +236 -42
  125. package/dist/language/flows/flow-store-for-session.d.ts +2 -2
  126. package/dist/language/flows/flow-store-for-session.js +2 -2
  127. package/dist/language/flows/flow-tools.js +58 -34
  128. package/dist/language/flows/flows.js +5 -2
  129. package/dist/language/flows/heal-run.js +6 -4
  130. package/dist/language/flows/recording/flake.d.ts +2 -2
  131. package/dist/language/flows/recording/tape/recordings.d.ts +30 -4
  132. package/dist/language/flows/recording/tape/recordings.js +40 -8
  133. package/dist/language/flows/replay-from-hud.d.ts +20 -0
  134. package/dist/language/flows/replay-from-hud.js +51 -0
  135. package/dist/language/flows/replay.d.ts +9 -5
  136. package/dist/language/flows/replay.js +27 -9
  137. package/dist/language/flows/suite/suite-flakes.js +2 -0
  138. package/dist/language/flows/suite-selection.d.ts +2 -15
  139. package/dist/language/flows/suite-selection.js +4 -3
  140. package/dist/language/flows/verify-change-tools.js +8 -4
  141. package/dist/launch-chromium.d.ts +27 -5
  142. package/dist/launch-chromium.js +38 -8
  143. package/dist/memory/cloud/cloud-config.d.ts +12 -0
  144. package/dist/memory/cloud/cloud-config.js +15 -0
  145. package/dist/memory/cloud/cloud-sync.d.ts +17 -14
  146. package/dist/memory/cloud/cloud-sync.js +12 -1
  147. package/dist/memory/cloud/harness-config.d.ts +26 -1
  148. package/dist/memory/cloud/harness-config.js +99 -4
  149. package/dist/memory/cloud/hud-notices-source.d.ts +21 -0
  150. package/dist/memory/cloud/hud-notices-source.js +128 -0
  151. package/dist/memory/cloud/memory-scope.d.ts +144 -0
  152. package/dist/memory/cloud/memory-scope.js +205 -0
  153. package/dist/memory/cloud/sync-cycle.d.ts +12 -3
  154. package/dist/memory/cloud/sync-cycle.js +16 -3
  155. package/dist/memory/cloud/sync-daemon.js +7 -5
  156. package/dist/memory/cloud/sync-disk.d.ts +1 -1
  157. package/dist/memory/cloud/sync-disk.js +5 -8
  158. package/dist/memory/impact/account-watch.d.ts +19 -0
  159. package/dist/memory/impact/account-watch.js +34 -0
  160. package/dist/memory/impact/impact-recorder.d.ts +8 -0
  161. package/dist/memory/impact/impact-recorder.js +12 -2
  162. package/dist/memory/impact/impact-store.d.ts +16 -0
  163. package/dist/memory/impact/impact-store.js +142 -26
  164. package/dist/memory/intent/intent-shard.d.ts +2 -2
  165. package/dist/memory/intent/intent-tools.js +80 -2
  166. package/dist/memory/journal/envelope.d.ts +8 -8
  167. package/dist/memory/journal/journal-recorder.js +7 -1
  168. package/dist/memory/journal/on-disk/retention.d.ts +3 -0
  169. package/dist/memory/journal/on-disk/retention.js +5 -0
  170. package/dist/memory/journal/on-disk/startup-maintenance.js +3 -1
  171. package/dist/memory/journal/on-disk/workspace-tiers.js +7 -4
  172. package/dist/memory/journal/session-end.js +6 -4
  173. package/dist/memory/project/artifact-root-resolver.d.ts +26 -1
  174. package/dist/memory/project/artifact-root-resolver.js +94 -13
  175. package/dist/memory/project/artifact-root.d.ts +42 -5
  176. package/dist/memory/project/artifact-root.js +87 -8
  177. package/dist/memory/project/dir/reticle-dir.d.ts +4 -2
  178. package/dist/memory/project/dir/reticle-dir.js +3 -1
  179. package/dist/memory/project/project-tools.js +22 -3
  180. package/dist/memory/project/session-root.d.ts +24 -1
  181. package/dist/memory/project/session-root.js +41 -5
  182. package/dist/memory/project/sync-status.d.ts +39 -0
  183. package/dist/memory/project/sync-status.js +90 -0
  184. package/dist/memory/recall/memory-tools.js +2 -0
  185. package/dist/memory/recall/project-memory.d.ts +19 -0
  186. package/dist/memory/recall/project-memory.js +45 -6
  187. package/dist/portal/bridge/bridge.d.ts +3 -1
  188. package/dist/portal/bridge/bridge.js +19 -2
  189. package/dist/portal/bridge/pairing-token.d.ts +9 -0
  190. package/dist/portal/bridge/pairing-token.js +17 -0
  191. package/dist/portal/input/real-input.d.ts +81 -10
  192. package/dist/portal/input/real-input.js +207 -22
  193. package/dist/portal/input/viewport-tools.js +3 -3
  194. package/dist/portal/pool/browser-pool.d.ts +22 -9
  195. package/dist/portal/pool/browser-pool.js +70 -35
  196. package/dist/portal/pool/playwright-launcher.d.ts +13 -0
  197. package/dist/portal/pool/playwright-launcher.js +17 -1
  198. package/dist/portal/pool/pool-contract.d.ts +7 -4
  199. package/dist/portal/session/command-payload.d.ts +8 -0
  200. package/dist/portal/session/command-payload.js +19 -0
  201. package/dist/portal/session/dev-server/dev-command.d.ts +12 -0
  202. package/dist/portal/session/dev-server/dev-command.js +61 -12
  203. package/dist/portal/session/facts/departure-note.d.ts +22 -0
  204. package/dist/portal/session/facts/departure-note.js +47 -0
  205. package/dist/portal/session/facts/session-replaced.d.ts +5 -0
  206. package/dist/portal/session/facts/session-replaced.js +5 -0
  207. package/dist/portal/session/first-run-wiring.d.ts +42 -0
  208. package/dist/portal/session/first-run-wiring.js +132 -0
  209. package/dist/portal/session/human/notes-ledger.d.ts +20 -0
  210. package/dist/portal/session/human/notes-ledger.js +55 -0
  211. package/dist/portal/session/human/review-store.d.ts +12 -0
  212. package/dist/portal/session/human/review-store.js +18 -10
  213. package/dist/portal/session/live-control-tools.d.ts +0 -13
  214. package/dist/portal/session/live-control-tools.js +28 -3
  215. package/dist/portal/session/no-session-diagnosis.d.ts +7 -0
  216. package/dist/portal/session/no-session-diagnosis.js +15 -0
  217. package/dist/portal/session/no-session-next-action.d.ts +11 -0
  218. package/dist/portal/session/no-session-next-action.js +31 -5
  219. package/dist/portal/session/no-session-watch.d.ts +14 -1
  220. package/dist/portal/session/no-session-watch.js +63 -10
  221. package/dist/portal/session/presence/session-mirror.js +3 -0
  222. package/dist/portal/session/serving-directory.d.ts +27 -0
  223. package/dist/portal/session/serving-directory.js +45 -0
  224. package/dist/portal/session/session-health.d.ts +7 -0
  225. package/dist/portal/session/session-health.js +6 -0
  226. package/dist/portal/session/session-info.d.ts +8 -1
  227. package/dist/portal/session/session-info.js +6 -0
  228. package/dist/portal/session/session-manager.d.ts +21 -0
  229. package/dist/portal/session/session-manager.js +44 -8
  230. package/dist/portal/session/session-reaper.js +4 -1
  231. package/dist/portal/session/session-successor.d.ts +5 -0
  232. package/dist/portal/session/session-tools.js +26 -3
  233. package/dist/portal/session/session.d.ts +7 -2
  234. package/dist/portal/session/session.js +21 -18
  235. package/dist/start-options.d.ts +6 -0
  236. package/dist/status-payload.d.ts +10 -0
  237. package/dist/status-payload.js +23 -0
  238. package/dist/surface/http-server.js +11 -1
  239. package/dist/surface/mcp/mcp-proxy.d.ts +5 -0
  240. package/dist/surface/mcp/mcp-proxy.js +36 -5
  241. package/dist/surface/mcp/mcp.d.ts +11 -6
  242. package/dist/surface/mcp/mcp.js +27 -13
  243. package/dist/surface/mcp/server-instructions.d.ts +4 -2
  244. package/dist/surface/mcp/server-instructions.js +14 -4
  245. package/dist/surface/mcp/shared-params.js +2 -2
  246. package/dist/surface/tools/act/navigation/navigate-arrival.d.ts +8 -0
  247. package/dist/surface/tools/act/navigation/navigate-arrival.js +56 -2
  248. package/dist/surface/tools/act/settle-in-flight.js +25 -3
  249. package/dist/surface/tools/act-sequence-tool.js +2 -2
  250. package/dist/surface/tools/act-tools.js +23 -7
  251. package/dist/surface/tools/assert/assert-verdict.d.ts +1 -1
  252. package/dist/surface/tools/assert/assert-verdict.js +17 -2
  253. package/dist/surface/tools/browser-tools.js +8 -1
  254. package/dist/surface/tools/chat-drives.d.ts +26 -0
  255. package/dist/surface/tools/chat-drives.js +165 -0
  256. package/dist/surface/tools/empty-session-list.d.ts +41 -0
  257. package/dist/surface/tools/empty-session-list.js +80 -0
  258. package/dist/surface/tools/error-recovery.d.ts +6 -0
  259. package/dist/surface/tools/error-recovery.js +37 -4
  260. package/dist/surface/tools/explore-tools.d.ts +10 -1
  261. package/dist/surface/tools/explore-tools.js +82 -13
  262. package/dist/surface/tools/feedback-tools.js +14 -0
  263. package/dist/surface/tools/harness-explore.d.ts +96 -14
  264. package/dist/surface/tools/harness-explore.js +227 -206
  265. package/dist/surface/tools/harness-plan.d.ts +12 -0
  266. package/dist/surface/tools/harness-plan.js +31 -0
  267. package/dist/surface/tools/harness-script.d.ts +8 -0
  268. package/dist/surface/tools/harness-script.js +342 -0
  269. package/dist/surface/tools/harness-toolset.d.ts +14 -1
  270. package/dist/surface/tools/harness-toolset.js +77 -6
  271. package/dist/surface/tools/invoke-tool.js +52 -1
  272. package/dist/surface/tools/lease-seed.d.ts +29 -0
  273. package/dist/surface/tools/lease-seed.js +123 -0
  274. package/dist/surface/tools/lease-tools.d.ts +3 -23
  275. package/dist/surface/tools/lease-tools.js +30 -131
  276. package/dist/surface/tools/live-call-text.js +2 -2
  277. package/dist/surface/tools/next-step.d.ts +19 -0
  278. package/dist/surface/tools/next-step.js +159 -0
  279. package/dist/surface/tools/observe-tools.js +2 -4
  280. package/dist/surface/tools/platform-moment.d.ts +12 -0
  281. package/dist/surface/tools/platform-moment.js +168 -0
  282. package/dist/surface/tools/query-hint-schema.d.ts +7 -0
  283. package/dist/surface/tools/query-hint-schema.js +10 -0
  284. package/dist/surface/tools/read-tools.js +8 -2
  285. package/dist/surface/tools/real-input-attempt.js +96 -13
  286. package/dist/surface/tools/tool-kit.d.ts +15 -1
  287. package/dist/surface/tools/tool-kit.js +4 -6
  288. package/dist/surface/tools/tools.js +20 -48
  289. package/dist/telemetry/hud-metrics.d.ts +2 -0
  290. package/dist/telemetry/hud-metrics.js +17 -0
  291. package/dist/telemetry/telemetry.d.ts +12 -0
  292. package/dist/telemetry/telemetry.js +43 -14
  293. package/dist/telemetry/verification-of.js +1 -0
  294. package/dist/wire-journal.js +4 -1
  295. package/docs/README.md +1 -1
  296. package/docs/agent-cheatsheet.md +1 -1
  297. package/docs/autodrive.md +29 -25
  298. package/docs/cli/doctor.mdx +6 -0
  299. package/docs/cli/drive.mdx +8 -2
  300. package/docs/cli/init.mdx +11 -13
  301. package/docs/cli/status.mdx +5 -1
  302. package/docs/cli/version.mdx +3 -3
  303. package/docs/cli.mdx +1 -1
  304. package/docs/desktop.mdx +5 -1
  305. package/docs/environment.mdx +3 -7
  306. package/docs/getting-started.md +13 -11
  307. package/docs/human-control.md +1 -2
  308. package/docs/index.mdx +4 -3
  309. package/docs/install-manual.mdx +2 -2
  310. package/docs/packages/tauri.mdx +2 -2
  311. package/docs/packages/vite-plugin.mdx +22 -1
  312. package/docs/packages.mdx +1 -1
  313. package/docs/quickstart.mdx +17 -7
  314. package/docs/telemetry.md +1 -1
  315. package/docs/tools/session-and-feedback.mdx +9 -9
  316. package/docs/usage.md +5 -2
  317. package/docs/what-is-recorded.md +13 -3
  318. package/docs/what-is-reticle.mdx +3 -1
  319. package/package.json +8 -12
  320. package/dist/features/harness/driver.d.ts +0 -50
  321. package/dist/features/harness/driver.js +0 -236
  322. package/dist/features/harness/jev-driver.d.ts +0 -188
  323. package/dist/features/harness/jev-driver.js +0 -678
  324. package/dist/features/harness/openai-driver.d.ts +0 -58
  325. package/dist/features/harness/openai-driver.js +0 -206
  326. package/dist/memory/project/dir/fill-value-store.d.ts +0 -32
  327. package/dist/memory/project/dir/fill-value-store.js +0 -88
  328. package/docs/local-registry.md +0 -143
@@ -1,4 +1,4 @@
1
- import { join } from 'node:path';
1
+ import { isAbsolute, join, relative, sep } from 'node:path';
2
2
  import { SAFE_SEGMENT_PATTERN, ReticleDir, fnv1a, projectCandidates, } from '@reticlehq/core';
3
3
  /**
4
4
  * Which `.reticle/` a session's artifacts belong in.
@@ -51,6 +51,10 @@ export function resolveArtifactRoot(query) {
51
51
  return { root: daemonRoot, reason: ArtifactRootReason.NO_MATCH };
52
52
  }
53
53
  if (matches.length > 1) {
54
+ const served = checkoutContaining(matches, query.servingDirectory);
55
+ if (served !== undefined) {
56
+ return { root: join(served, ReticleDir.ROOT), reason: ArtifactRootReason.MATCHED_PROJECT };
57
+ }
54
58
  return {
55
59
  root: daemonRoot,
56
60
  reason: ArtifactRootReason.AMBIGUOUS,
@@ -66,6 +70,24 @@ export function resolveArtifactRoot(query) {
66
70
  }
67
71
  return { root: join(directory, ReticleDir.ROOT), reason: ArtifactRootReason.MATCHED_PROJECT };
68
72
  }
73
+ /**
74
+ * The checkout the serving directory sits in, or undefined when it is in none of them.
75
+ *
76
+ * Deepest wins, because a worktree can live INSIDE the primary checkout (`.claude/worktrees/x`) and
77
+ * then sits under both; the deeper one is the one the server actually runs in. `relative` rather
78
+ * than a string prefix, so `/wt/pr-77` is not read as inside `/wt/pr-7`, and on every platform.
79
+ */
80
+ function checkoutContaining(matches, servingDirectory) {
81
+ if (servingDirectory === undefined || 0 === servingDirectory.length)
82
+ return undefined;
83
+ const inside = matches
84
+ .map((candidate) => candidate.directory)
85
+ .filter((directory) => {
86
+ const rel = relative(directory, servingDirectory);
87
+ return '' === rel || ('..' !== rel && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
88
+ });
89
+ return inside.sort((a, b) => b.length - a.length)[0];
90
+ }
69
91
  /**
70
92
  * One entry per directory.
71
93
  *
@@ -92,11 +114,21 @@ function dedupeByDirectory(matches) {
92
114
  * is a cache of something `init` saw once. Order only decides which duplicate survives dedupe, and
93
115
  * the one confirmed a moment ago is the better survivor.
94
116
  */
95
- export function projectCandidatesFrom(discovery, registry) {
117
+ export function projectCandidatesFrom(discovery, registry, devServers = []) {
96
118
  const discovered = discovery.found.flatMap((config) => config.projectId === undefined || 0 === config.projectId.length
97
119
  ? []
98
120
  : [{ projectId: config.projectId, directory: config.directory }]);
99
- return [...discovered, ...projectCandidates(registry)];
121
+ const known = [...discovered, ...projectCandidates(registry)];
122
+ // A dev server's own announcement of its project id and root, ONLY for an id nothing above names.
123
+ // It is the one source that knows an app wired by the build plugin alone (no `.reticle.json`, never
124
+ // `init`-ed), whose runs otherwise went to `unmatched/`, which nothing syncs. It fills gaps and never
125
+ // competes: `init` at a monorepo root names the root while the dev server names the app's subfolder,
126
+ // and counting both refused a project that resolved fine before.
127
+ const named = new Set(known.map((candidate) => candidate.projectId));
128
+ const announced = devServers.flatMap((server) => server.projectId === undefined || 0 === server.projectId.length || named.has(server.projectId)
129
+ ? []
130
+ : [{ projectId: server.projectId, directory: server.root }]);
131
+ return [...known, ...announced];
100
132
  }
101
133
  /** Where evidence goes when no project could be named and the daemon is a guest in this tree. */
102
134
  export const UNMATCHED_SUBDIR = 'unmatched';
@@ -132,23 +164,70 @@ const ORIGIN_BUCKET_PREFIX = 'origin-';
132
164
  * Two cases, and only one of them was ever the intended behaviour:
133
165
  *
134
166
  * - the daemon is sitting IN a Reticle project (a developer who ran `reticle serve` in their own
135
- * app). Its root is the right answer, and it is the case the old fallback was written for.
136
- * - the daemon is a guest — no `.reticle.json`, no `.reticle/` already there. Then the evidence
137
- * goes to the user's own `~/.reticle/unmatched/<projectId>`, which is Reticle's to write.
167
+ * app), and the session is asking about THAT project. Its root is the right answer, and it is
168
+ * the case the old fallback was written for.
169
+ * - otherwise — a guest in the tree, or a daemon in its own checkout serving a DIFFERENT project.
170
+ * Then the evidence goes to the user's own `~/.reticle/unmatched/<projectId>`, which is
171
+ * Reticle's to write.
138
172
  *
139
173
  * It is never dropped. A verdict with nowhere to live is a worse failure than one in an unexpected
140
174
  * place, and the caller says out loud where it went.
141
175
  *
176
+ * ## The second case in that list used to be missing, and it is not an edge case
177
+ *
178
+ * `daemonIsProject` answers "is the daemon sitting in SOME Reticle project". The guard below read it
179
+ * as "this session's project", and those are different questions whenever the two ids differ. Run
180
+ * the daemon in repo A, then connect an app from a git worktree B of that same repo: B has its own
181
+ * `projectId` in its own `.reticle.json`, and discovery cannot see it — it walks out from A's
182
+ * `process.cwd()` and never crosses into a sibling checkout — so this function is reached with a
183
+ * NAMED project and the daemon's root came back anyway. B's intents, flows and runs were written
184
+ * into A's checkout, and the returned path said A, so nothing looked wrong.
185
+ *
186
+ * A real identity always wins. The daemon's root is only the answer when it is that identity's own
187
+ * root, or when no identity was named at all.
188
+ *
142
189
  * `projectId` arrives in HELLO from the page, so it is untrusted input on a path join and is held
143
- * to one safe segment — the same guard session ids and flow names already pass.
190
+ * to one safe segment — the same guard session ids and flow names already pass. `daemonProjectId`
191
+ * is read from the daemon's own `.reticle.json` and gets the same treatment, because equality
192
+ * against a guarded value is only as strong as the weaker side.
144
193
  */
145
194
  export function unmatchedRoot(query) {
146
- if (query.daemonIsProject)
195
+ if (query.daemonIsProject && isTheDaemonsOwnProject(query.daemonProjectId, query.projectId))
147
196
  return query.daemonRoot;
148
197
  const id = query.projectId ?? '';
149
198
  const named = SAFE_SEGMENT_PATTERN.test(id) && !id.includes('..');
150
199
  return join(query.home, ReticleDir.ROOT, UNMATCHED_SUBDIR, named ? id : unnamedSegment(query.origin));
151
200
  }
201
+ /**
202
+ * May the daemon write this session's artifacts into its own `.reticle/`?
203
+ *
204
+ * Two ways to qualify, and the difference between them is the whole bug: belonging to the directory
205
+ * is not the same as owning it.
206
+ *
207
+ * - Nobody named a project (a pre-2.0 SDK sends no projectId). There is no identity to disagree
208
+ * with, and the daemon's own root is the only thing on offer.
209
+ * - The named project IS the daemon's own, and the daemon can prove it — `.reticle.json` sits in
210
+ * its directory and declares the same id. Both sides go through the same segment guard, because
211
+ * an equality between a guarded value and an unguarded one is only as strong as the loose side.
212
+ *
213
+ * A NAMED project the daemon cannot match declines, including when it has a `.reticle/` but no
214
+ * readable id. Declining costs the session its home directory and buys the guarantee that one
215
+ * checkout never receives another's ledger; the alternative is a wrong answer that reports success.
216
+ *
217
+ * The two ids are REQUIRED parameters rather than one object, and that is load-bearing. Taking a
218
+ * partial object let a caller omit a field and still compile — and an omitted `projectId` reads as
219
+ * "nobody named a project", which answers `true` and puts the session straight back into the daemon's
220
+ * checkout. That is the original defect, reachable by a typo rather than by a decision.
221
+ */
222
+ function isTheDaemonsOwnProject(daemonProjectId, projectId) {
223
+ if (projectId === undefined || 0 === projectId.length)
224
+ return true;
225
+ if (daemonProjectId === undefined || 0 === daemonProjectId.length)
226
+ return false;
227
+ return (daemonProjectId === projectId &&
228
+ SAFE_SEGMENT_PATTERN.test(daemonProjectId) &&
229
+ !daemonProjectId.includes('..'));
230
+ }
152
231
  /** Hashed, not spelled: an origin carries a host and a port, and neither belongs in a path segment. */
153
232
  function unnamedSegment(origin) {
154
233
  if (origin === undefined || 0 === origin.trim().length)
@@ -17,8 +17,6 @@ export interface ReticleDirPaths {
17
17
  impact: string;
18
18
  /**.../.reticle/intent.json — the LEGACY single ledger, read until the first write migrates it into .reticle/intent/ */
19
19
  intent: string;
20
- /**.../.reticle/fill-values.json (what a drive types into each labelled field — git-checked) */
21
- fillValues: string;
22
20
  /**.../.reticle/visual (PNG baselines + diffs) */
23
21
  visual: string;
24
22
  /**.../.reticle/runs (verification-run artifacts) */
@@ -37,6 +35,10 @@ export interface ReticleDirPaths {
37
35
  coverage: string;
38
36
  /**.../.reticle/assertion-tiers.json (last-passing assertion tiers; anti-reward-hacking baseline) */
39
37
  tiers: string;
38
+ /**.../.reticle/request.json (the user's latest request, relayed by the agent, classified) */
39
+ request: string;
40
+ /** .reticle/plans/ — every Harness drive plan made here, and how it went. */
41
+ plans: string;
40
42
  }
41
43
  export declare function reticleDirPaths(root: string): ReticleDirPaths;
42
44
  /** The verification-run artifact path for `runId`.reticle/runs/<runId>.json). */
@@ -10,7 +10,6 @@ export function reticleDirPaths(root) {
10
10
  project: join(root, ReticleDir.PROJECT_FILE),
11
11
  impact: join(root, ReticleDir.IMPACT_FILE),
12
12
  intent: join(root, ReticleDir.INTENT_FILE),
13
- fillValues: join(root, ReticleDir.FILL_VALUES_FILE),
14
13
  visual: join(root, ReticleDir.VISUAL_SUBDIR),
15
14
  runs: join(root, ReticleDir.RUNS_SUBDIR),
16
15
  sessions: join(root, ReticleDir.SESSIONS_SUBDIR),
@@ -20,6 +19,9 @@ export function reticleDirPaths(root) {
20
19
  flake: join(root, ReticleDir.FLAKE_FILE),
21
20
  coverage: join(root, ReticleDir.COVERAGE_FILE),
22
21
  tiers: join(root, ReticleDir.TIERS_FILE),
22
+ // Beside `intent/`, not in it: `intent/` is committed, and this is the user's own words.
23
+ request: join(root, ReticleDir.REQUEST_FILE),
24
+ plans: join(root, ReticleDir.PLANS_SUBDIR),
23
25
  };
24
26
  }
25
27
  /** The verification-run artifact path for `runId`.reticle/runs/<runId>.json). */
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { describeSync, overallStatus, readSyncSummary } from './sync-status.js';
2
3
  import { sessionRoot } from './session-root.js';
3
4
  import { projectForRoot } from './project-for-root.js';
4
5
  import { ProjectReadError, RunStatus } from '@reticlehq/core';
@@ -94,6 +95,7 @@ export const PROJECT_TOOLS = [
94
95
  description: 'Read cross-run history from .reticle/project.json — the memory of how past runs behaved. With { name } it also returns the last run for that flow plus a diff-vs-last summary (status change, regressed flag, consoleErrors/driftSteps deltas) so you can answer "did it behave like last time?". `diff` is the lightweight status/console/drift delta vs the previous run of that NAME; `runDiff` is the per-flow duration/status delta between the two most-recent full verification artifacts ("step 3: 412ms -> 987ms (+140%), +2 requests"). When logged in to Reticle, also returns `cloud`: the team\'s server-side regression report (broken/changed flows vs before) — the durable memory a fresh or context-lost agent can rely on even when local history is empty. Returns { runs, learned?, lastRun?, diff?, runDiff?, cloud? } or { error, reason, cloud? }.',
95
96
  inputSchema: {
96
97
  name: z.string().optional().describe('Filter runs by this name. Omit to return all runs.'),
98
+ push: z.boolean().optional().describe('Sync with the platform now, before answering.'),
97
99
  limit: countSchema
98
100
  .optional()
99
101
  .describe('Most-recent N runs to return. Defaults to 25 — the full history is unbounded and grows with every run (measured at ~20KB / ~5,000 tokens on a modest project), which is a large slice of an agent context for data it mostly does not read. `totalRuns` always reports the true count, so a cap is never silent.'),
@@ -110,14 +112,31 @@ export const PROJECT_TOOLS = [
110
112
  cloud: z.unknown().optional(),
111
113
  },
112
114
  handler: async (deps, args) => {
113
- const cloud = await cloudRegression(deps, asString(args['sessionId']));
114
- const withCloud = (obj) => cloud === undefined ? obj : { ...obj, cloud };
115
+ // A push asked for is done first, so the status below describes what it achieved.
116
+ if (true === args['push'])
117
+ await deps.syncNow?.().catch(() => undefined);
118
+ const root = sessionRoot(deps, asString(args['sessionId']));
119
+ const linked = (await deps.linkedCloud?.().catch(() => null)) ?? null;
120
+ const summary = readSyncSummary(root, null !== linked);
121
+ const sync = {
122
+ ...summary,
123
+ status: overallStatus(summary),
124
+ said: describeSync(summary, deps.now()),
125
+ };
126
+ const regression = await cloudRegression(deps, asString(args['sessionId']));
127
+ // Where this project's work stands with the platform rides in `cloud` beside the team's
128
+ // regression report: the two answers to "what does the platform know about this project".
129
+ const cloud = {
130
+ ...('object' === typeof regression && null !== regression ? regression : {}),
131
+ sync,
132
+ };
133
+ const withCloud = (obj) => ({ ...obj, cloud });
115
134
  const project = projectForRoot(deps, sessionRoot(deps, asString(args['sessionId'])));
116
135
  const read = await project.read();
117
136
  if (!read.ok) {
118
137
  return withCloud({
119
138
  error: read.reason === ProjectReadError.MISSING
120
- ? 'no .reticle/project.json yet — run a flow (reticle_flow_replay) or reticle_run_record first'
139
+ ? 'no .reticle/project.json yet — the first verified run writes it: drive one step with reticle_act_and_wait and an `until`'
121
140
  : '.reticle/project.json is malformed — it will self-heal on the next recorded run',
122
141
  reason: read.reason,
123
142
  });
@@ -1,5 +1,21 @@
1
1
  import type { ProjectId } from '@reticlehq/core';
2
2
  import type { ToolDeps } from '../../surface/tools/tool-kit.js';
3
+ /**
4
+ * Which project a call is about, and the page origin it came through.
5
+ *
6
+ * The projectId alone is not an address: every git worktree carries the same committed
7
+ * `.reticle.json`, so a daemon serving several worktrees sees one id for all of them. The origin
8
+ * names the dev server that served the page, and the resolver uses it to pick the checkout that
9
+ * server runs in. Carried as one value so no caller can resolve with half of it -- a flow saved
10
+ * through the worktree and replayed through the id alone would land in one tree and be read from
11
+ * another.
12
+ */
13
+ export interface ProjectTarget {
14
+ projectId: ProjectId | undefined;
15
+ origin?: string | undefined;
16
+ }
17
+ /** The target for a caller that genuinely holds no session -- the id is all there is. */
18
+ export declare function projectOnly(projectId: ProjectId | undefined): ProjectTarget;
3
19
  /**
4
20
  * The `.reticle` directory a tool call's artifacts belong in.
5
21
  *
@@ -24,7 +40,7 @@ export declare function sessionRoot(deps: ToolDeps, sessionId: string | undefine
24
40
  * place and the id from another. `sessionRoot` is now this function plus a lookup, so the two
25
41
  * cannot disagree about what a resolved root is.
26
42
  */
27
- export declare function rootForProjectId(deps: ToolDeps, projectId: ProjectId | undefined): string;
43
+ export declare function rootForTarget(deps: ToolDeps, target: ProjectTarget): string;
28
44
  /**
29
45
  * The PROJECT directory -- one level above `.reticle` -- for a caller that runs a tool in a tree
30
46
  * rather than writing a file into one. `git diff` is the case: it was being run in the daemon's
@@ -50,3 +66,10 @@ export declare function projectDirFor(deps: ToolDeps, sessionId: string | undefi
50
66
  * there is no answer either.
51
67
  */
52
68
  export declare function sessionProjectId(deps: ToolDeps, sessionId: string | undefined): ProjectId | undefined;
69
+ /**
70
+ * `sessionTarget` for the paths that degrade instead of refusing: replay and learning resolve a
71
+ * missing session to the global store, and keep doing so -- with the origin when there is one.
72
+ */
73
+ export declare function safeSessionTarget(deps: ToolDeps, sessionId: string | undefined): ProjectTarget;
74
+ /** `sessionProjectId` plus the origin the session's page was served from. Same refusals. */
75
+ export declare function sessionTarget(deps: ToolDeps, sessionId: string | undefined): ProjectTarget;
@@ -1,4 +1,23 @@
1
1
  import { dirname } from 'node:path';
2
+ /**
3
+ * The scheme://host:port a session's page was served from. Local rather than the session manager's
4
+ * copy because `memory/project` does not reach `portal/session`, and five lines are cheaper than a
5
+ * new dependency between the two.
6
+ */
7
+ function originOf(url) {
8
+ if (url === undefined)
9
+ return undefined;
10
+ try {
11
+ return new URL(url).origin;
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ }
17
+ /** The target for a caller that genuinely holds no session -- the id is all there is. */
18
+ export function projectOnly(projectId) {
19
+ return { projectId };
20
+ }
2
21
  /**
3
22
  * The `.reticle` directory a tool call's artifacts belong in.
4
23
  *
@@ -26,7 +45,7 @@ export function sessionRoot(deps, sessionId) {
26
45
  // nothing its answer could change, so that one case skips the lookup and costs what it always
27
46
  // did. Every embedder of this engine, and every older construction of ToolDeps, is on it.
28
47
  const nothingToRefuse = deps.artifactRootFor === undefined && sessionId === undefined;
29
- return rootForProjectId(deps, nothingToRefuse ? undefined : sessionProjectId(deps, sessionId));
48
+ return rootForTarget(deps, nothingToRefuse ? projectOnly(undefined) : sessionTarget(deps, sessionId));
30
49
  }
31
50
  /**
32
51
  * The same answer for a caller that already holds the projectId rather than a sessionId.
@@ -37,8 +56,8 @@ export function sessionRoot(deps, sessionId) {
37
56
  * place and the id from another. `sessionRoot` is now this function plus a lookup, so the two
38
57
  * cannot disagree about what a resolved root is.
39
58
  */
40
- export function rootForProjectId(deps, projectId) {
41
- return deps.artifactRootFor?.(projectId).root ?? deps.reticleRoot;
59
+ export function rootForTarget(deps, target) {
60
+ return deps.artifactRootFor?.(target.projectId, target.origin).root ?? deps.reticleRoot;
42
61
  }
43
62
  /**
44
63
  * The PROJECT directory -- one level above `.reticle` -- for a caller that runs a tool in a tree
@@ -74,12 +93,29 @@ export function projectDirFor(deps, sessionId) {
74
93
  * from this file so they cannot disagree again.
75
94
  */
76
95
  export function sessionProjectId(deps, sessionId) {
96
+ return sessionTarget(deps, sessionId).projectId;
97
+ }
98
+ /**
99
+ * `sessionTarget` for the paths that degrade instead of refusing: replay and learning resolve a
100
+ * missing session to the global store, and keep doing so -- with the origin when there is one.
101
+ */
102
+ export function safeSessionTarget(deps, sessionId) {
103
+ try {
104
+ return sessionTarget(deps, sessionId);
105
+ }
106
+ catch {
107
+ return projectOnly(undefined);
108
+ }
109
+ }
110
+ /** `sessionProjectId` plus the origin the session's page was served from. Same refusals. */
111
+ export function sessionTarget(deps, sessionId) {
77
112
  try {
78
- return deps.sessions.resolve(sessionId).projectId;
113
+ const session = deps.sessions.resolve(sessionId);
114
+ return { projectId: session.projectId, origin: originOf(session.url) };
79
115
  }
80
116
  catch (cause) {
81
117
  if (sessionId === undefined)
82
- return undefined;
118
+ return projectOnly(undefined);
83
119
  throw unresolvedTargetRefusal(sessionId, cause);
84
120
  }
85
121
  }
@@ -0,0 +1,39 @@
1
+ import type { CloudSyncState } from '../cloud/sync-cycle.js';
2
+ export declare const SyncStatus: {
3
+ readonly ON_PLATFORM: "on-platform";
4
+ readonly PENDING: "pending";
5
+ readonly REFUSED: "refused";
6
+ readonly LOCAL_ONLY: "local-only";
7
+ };
8
+ export type SyncStatus = (typeof SyncStatus)[keyof typeof SyncStatus];
9
+ export interface SyncSummary {
10
+ linked: boolean;
11
+ /** Runs on this machine. */
12
+ runs: number;
13
+ onPlatform: number;
14
+ pending: number;
15
+ /** The runs the platform refused in their current form, with its reason. */
16
+ refused: {
17
+ runId: string;
18
+ reason: string;
19
+ }[];
20
+ /** Refusals beyond the ones named. */
21
+ refusedMore: number;
22
+ lastPushAt?: number;
23
+ /** Why the last cycle failed, when it did. */
24
+ lastError?: string;
25
+ }
26
+ export declare function summarizeSync(input: {
27
+ linked: boolean;
28
+ runs: readonly {
29
+ runId: string;
30
+ payload: unknown;
31
+ }[];
32
+ state: CloudSyncState;
33
+ }): SyncSummary;
34
+ /** This project's summary, read from its `.reticle`. Never throws: an unreadable record is empty. */
35
+ export declare function readSyncSummary(reticleRoot: string, linked: boolean): SyncSummary;
36
+ /** One status for the whole project: the worst thing true of it. */
37
+ export declare function overallStatus(summary: SyncSummary): SyncStatus;
38
+ /** The summary in one sentence an agent can repeat to a person. */
39
+ export declare function describeSync(summary: SyncSummary, now: number): string;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Where this project's work stands with the Reticle platform, run by run.
3
+ *
4
+ * Read from the sync cycle's own record (`.reticle/cloud-state.json`): the content hash of every run
5
+ * the platform ACCEPTED, and the reason for every run it REFUSED. A run whose current content matches
6
+ * an accepted hash is on the platform; one whose current content was refused is refused, with the
7
+ * platform's own sentence; anything else is waiting for the next cycle. On a machine that is not
8
+ * linked, everything is local only.
9
+ *
10
+ * Nothing here talks to the network. It answers "did my work go up?" from what the machine already
11
+ * knows, so it can ride on every verdict without costing a request.
12
+ */
13
+ import { diskSource, readCloudState } from '../cloud/sync-disk.js';
14
+ import { hashPayload } from '../cloud/sync-hash.js';
15
+ export const SyncStatus = {
16
+ ON_PLATFORM: 'on-platform',
17
+ PENDING: 'pending',
18
+ REFUSED: 'refused',
19
+ LOCAL_ONLY: 'local-only',
20
+ };
21
+ /** How many refusals a summary names before it only counts them. */
22
+ const MAX_NAMED_REFUSALS = 3;
23
+ export function summarizeSync(input) {
24
+ const { linked, runs, state } = input;
25
+ let onPlatform = 0;
26
+ let pending = 0;
27
+ const refused = [];
28
+ if (linked) {
29
+ for (const run of runs) {
30
+ const hash = hashPayload(run.payload);
31
+ const refusal = state.refusedRuns?.[run.runId];
32
+ if (state.sentRunHashes?.[run.runId] === hash)
33
+ onPlatform += 1;
34
+ else if (refusal !== undefined && refusal.payloadHash === hash)
35
+ refused.push({ runId: run.runId, reason: refusal.reason });
36
+ else
37
+ pending += 1;
38
+ }
39
+ }
40
+ return {
41
+ linked,
42
+ runs: runs.length,
43
+ onPlatform,
44
+ pending,
45
+ refused: refused.slice(0, MAX_NAMED_REFUSALS),
46
+ refusedMore: Math.max(0, refused.length - MAX_NAMED_REFUSALS),
47
+ ...(state.lastPushAt === undefined ? {} : { lastPushAt: state.lastPushAt }),
48
+ ...(state.lastError === undefined || 0 === state.lastError.length
49
+ ? {}
50
+ : { lastError: state.lastError }),
51
+ };
52
+ }
53
+ /** This project's summary, read from its `.reticle`. Never throws: an unreadable record is empty. */
54
+ export function readSyncSummary(reticleRoot, linked) {
55
+ let runs = [];
56
+ try {
57
+ runs = diskSource(reticleRoot).runs();
58
+ }
59
+ catch {
60
+ runs = [];
61
+ }
62
+ return summarizeSync({ linked, runs, state: readCloudState(reticleRoot) });
63
+ }
64
+ /** One status for the whole project: the worst thing true of it. */
65
+ export function overallStatus(summary) {
66
+ if (!summary.linked)
67
+ return SyncStatus.LOCAL_ONLY;
68
+ if (0 < summary.refused.length)
69
+ return SyncStatus.REFUSED;
70
+ if (0 < summary.pending || summary.lastError !== undefined)
71
+ return SyncStatus.PENDING;
72
+ return SyncStatus.ON_PLATFORM;
73
+ }
74
+ /** The summary in one sentence an agent can repeat to a person. */
75
+ export function describeSync(summary, now) {
76
+ if (!summary.linked)
77
+ return `${String(summary.runs)} run(s) exist only on this machine; nothing is synced until the project is linked (npx @reticlehq/server connect).`;
78
+ const ago = summary.lastPushAt === undefined
79
+ ? 'nothing pushed yet'
80
+ : `last push ${String(Math.max(0, Math.round((now - summary.lastPushAt) / 1000)))}s ago`;
81
+ const parts = [`${String(summary.onPlatform)} of ${String(summary.runs)} run(s) on the platform`];
82
+ if (0 < summary.pending)
83
+ parts.push(`${String(summary.pending)} waiting for the next push`);
84
+ const named = summary.refused.map((r) => `${r.runId}: ${r.reason}`);
85
+ if (0 < named.length)
86
+ parts.push(`refused by the platform: ${named.join('; ')}${0 < summary.refusedMore ? ` (+${String(summary.refusedMore)} more)` : ''}`);
87
+ if (summary.lastError !== undefined)
88
+ parts.push(`the last push failed: ${summary.lastError}`);
89
+ return `${parts.join('; ')} (${ago}).`;
90
+ }
@@ -30,6 +30,8 @@ const ADVICE = {
30
30
  [MemoryUnavailable.NOT_LINKED]: 'this project is not linked to a Reticle workspace, so there is no shared memory to read. Run `npx @reticlehq/server login` in the project to link it.',
31
31
  [MemoryUnavailable.DISABLED]: 'this project is linked but memory sync is turned off in .reticle/cloud.json, so nothing is being shared. Enable `sync.memory` to use it.',
32
32
  [MemoryUnavailable.UNREACHABLE]: 'the workspace could not be reached, or refused the key. Nothing is wrong with this project — try again, or check the link with `reticle status`.',
33
+ [MemoryUnavailable.UNVERIFIED]: 'the server answered, but the answer could not be shown to be about THIS project, so none of it is being reported. This is not an empty corpus: do NOT conclude this project knows nothing. Check the link with `reticle status`, and retry once the workspace is reachable.',
34
+ [MemoryUnavailable.UNREADABLE]: 'the workspace answered about THIS project, but the body was a shape this build cannot read entries out of. Retrying will not change it — this is not an empty corpus and not a link problem. Check the workspace version, and that `reticle memory` from the same terminal returns a `{ projectId, entries }` body.',
33
35
  };
34
36
  export const MEMORY_TOOLS = [
35
37
  {
@@ -7,6 +7,25 @@ export declare const MemoryUnavailable: {
7
7
  readonly DISABLED: "memory-sync-disabled";
8
8
  /** The server was reached and said no, or could not be reached at all. */
9
9
  readonly UNREACHABLE: "unreachable";
10
+ /**
11
+ * The server answered, and the answer could not be shown to be about THIS project: its envelope
12
+ * named another one, or named none at all.
13
+ *
14
+ * Its own reason rather than an empty list, because the two are opposite facts about the corpus.
15
+ * "This project knows nothing" is something an agent can act on; "we cannot tell whose knowledge
16
+ * this is" is something it must not, and an empty list reports the first while meaning the second.
17
+ */
18
+ readonly UNVERIFIED: "unverified";
19
+ /**
20
+ * The server answered about THIS project, and the body is a shape this build cannot read entries
21
+ * out of — no `entries` field, or one that is not a list.
22
+ *
23
+ * Apart from `UNREACHABLE` on purpose. That reason's advice is "try again, or check the link", and
24
+ * neither applies: the workspace WAS reached, its answer IS this project's, and the shape will be
25
+ * the same on the second attempt. Sending an agent to retry it costs a round trip and teaches it
26
+ * to distrust a link that is fine, which is what a reason is for.
27
+ */
28
+ readonly UNREADABLE: "unreadable";
10
29
  };
11
30
  export type MemoryUnavailable = (typeof MemoryUnavailable)[keyof typeof MemoryUnavailable];
12
31
  export interface KnownThing {
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { cloudFetch } from '../cloud/cloud-sync.js';
15
15
  import { resolveProjectCloud } from '../cloud/cloud-config.js';
16
+ import { isReadableMemoryScope, memoryReadUrl, scopedMemoryEntries, scopeOfMemoryResponse, } from '../cloud/memory-scope.js';
16
17
  /** Named so a read from the tool surface is distinguishable from the CLI's and from replay's. */
17
18
  const MCP_AGENT_ID = 'reticle-mcp';
18
19
  /** Why a lookup returned nothing. Each one is a different thing for the agent to do next. */
@@ -23,6 +24,25 @@ export const MemoryUnavailable = {
23
24
  DISABLED: 'memory-sync-disabled',
24
25
  /** The server was reached and said no, or could not be reached at all. */
25
26
  UNREACHABLE: 'unreachable',
27
+ /**
28
+ * The server answered, and the answer could not be shown to be about THIS project: its envelope
29
+ * named another one, or named none at all.
30
+ *
31
+ * Its own reason rather than an empty list, because the two are opposite facts about the corpus.
32
+ * "This project knows nothing" is something an agent can act on; "we cannot tell whose knowledge
33
+ * this is" is something it must not, and an empty list reports the first while meaning the second.
34
+ */
35
+ UNVERIFIED: 'unverified',
36
+ /**
37
+ * The server answered about THIS project, and the body is a shape this build cannot read entries
38
+ * out of — no `entries` field, or one that is not a list.
39
+ *
40
+ * Apart from `UNREACHABLE` on purpose. That reason's advice is "try again, or check the link", and
41
+ * neither applies: the workspace WAS reached, its answer IS this project's, and the shape will be
42
+ * the same on the second attempt. Sending an agent to retry it costs a round trip and teaches it
43
+ * to distrust a link that is fine, which is what a reason is for.
44
+ */
45
+ UNREADABLE: 'unreadable',
26
46
  };
27
47
  /** Defensive: the wire is somebody else's server, so every field is checked before it is trusted. */
28
48
  const asKnown = (raw) => {
@@ -58,9 +78,15 @@ export async function readProjectMemory(fs, root, home, env, opts) {
58
78
  return { ok: false, reason: MemoryUnavailable.NOT_LINKED };
59
79
  if (!cloud.policy.memory)
60
80
  return { ok: false, reason: MemoryUnavailable.DISABLED };
61
- const query = opts.subject === undefined ? '' : `?subject=${encodeURIComponent(opts.subject)}`;
81
+ // Scoped to the LINKED project, not to whatever the key happens to cover. A workspace can hold
82
+ // more than one repo, and a read that named no project could be answered with another one's
83
+ // established knowledge — indistinguishable from this project's, and acted on. See memory-scope.
84
+ const url = memoryReadUrl(cloud.config.url, {
85
+ projectId: cloud.projectId,
86
+ subject: opts.subject,
87
+ });
62
88
  try {
63
- const res = await cloudFetch(`${cloud.config.url}/v1/memory${query}`, {
89
+ const res = await cloudFetch(url, {
64
90
  method: 'GET',
65
91
  headers: {
66
92
  authorization: `Bearer ${cloud.config.apiKey}`,
@@ -75,10 +101,23 @@ export async function readProjectMemory(fs, root, home, env, opts) {
75
101
  // property yields the function, and every downstream field is undefined — a silent "the project
76
102
  // knows nothing" that is indistinguishable from the honest empty case. It cost a live drive to
77
103
  // find once already.
78
- const body = (await res.json());
79
- const entries = body?.entries;
80
- if (!Array.isArray(entries))
81
- return { ok: false, reason: MemoryUnavailable.UNREACHABLE };
104
+ const body = await res.json();
105
+ // The envelope first, and it decides everything. The platform names the project on the response
106
+ // and NOT on each entry, so an entry-level filter alone keeps a whole sibling workspace while
107
+ // looking like it did something — see `memory-scope.ts`. A response that names another project,
108
+ // or names none, is UNVERIFIED: not empty, and not this project's.
109
+ const entries = scopedMemoryEntries(body, cloud.projectId);
110
+ if (entries === undefined) {
111
+ return {
112
+ ok: false,
113
+ reason: isReadableMemoryScope(scopeOfMemoryResponse(body, cloud.projectId))
114
+ ? MemoryUnavailable.UNREADABLE // scoped fine, but no readable `entries` — a shape we do not know
115
+ : MemoryUnavailable.UNVERIFIED,
116
+ };
117
+ }
118
+ // The entry-level pass is redundant against the envelope check above and kept for a server that
119
+ // labels entries instead. `total` is counted AFTER it, so a truncated list still tells the truth
120
+ // about how many of THIS project's statements there were.
82
121
  const known = entries.map(asKnown).filter((k) => k !== null);
83
122
  return {
84
123
  ok: true,
@@ -110,7 +110,9 @@ export declare class Bridge {
110
110
  * Register a handler for the panel's harness switch. Optional for the same reason as the above: a
111
111
  * bridge built without one ignores the request rather than refusing it.
112
112
  */
113
- attachHarnessRequest(handler: (enabled: boolean) => void): void;
113
+ attachHarnessRequest(handler: (enabled: boolean, session: Session) => void): void;
114
+ /** Register a handler for the panel's Sign in. Optional, like the above: without one it is ignored. */
115
+ attachSigninRequest(handler: () => void): void;
114
116
  /** Register a handler to run when a session connects. Additive — every handler runs. */
115
117
  attachSessionCreate(handler: (session: Session) => void): void;
116
118
  close(): Promise<void>;