@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,8 +1,13 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
1
3
  import { z } from 'zod';
4
+ import { StatementSource, classifyStatement, splitStatements, } from '@reticlehq/core/artifacts';
5
+ import { redactFeedbackText } from '../../telemetry/feedback.js';
6
+ import { reticleDirPaths } from '../project/dir/reticle-dir.js';
2
7
  import { IntentStore } from './intent-store.js';
3
8
  import { IntentShardStore } from './intent-shard-store.js';
4
9
  import { IntentStatus } from './intent-shard.js';
5
- import { ReticleTool } from '@reticlehq/core';
10
+ import { ReticleDir, ReticleTool, apiKeyFrom } from '@reticlehq/core';
6
11
  import { sessionIdShape } from '../../surface/tools/tool-kit.js';
7
12
  import { PredicateSchema } from '@reticlehq/engine/question/predicate/predicate.js';
8
13
  import { sessionRoot } from '../project/session-root.js';
@@ -32,7 +37,7 @@ const MIGRATE = 'migrate';
32
37
  export const INTENT_TOOLS = [
33
38
  {
34
39
  name: ReticleTool.INTENT,
35
- description: 'Record what a change is SUPPOSED to make true, as a durable statement ABOUT THE PRODUCT that a teammate who was not here will understand in six months — name the behaviour, not this run or its step number, and never "renders cleanly", which nothing can check. It is SHARED memory: pooled per project and read back by later agents. Capture it while you still know — then verification does not have to re-derive it from the DOM later. { action:"declare", intents:[{ id, statement, surface? }] } takes prose and needs NO predicate: at declare time there is often no route, no ref and no code yet, and a predicate demanded there is just a mechanism. Declare EARLY (as you build) and batch them — one call per feature is the whole budget. { action:"bind", id, binding } attaches the predicate that would prove it once you know how; an intent with no binding is not a failure, it is the most interesting row in the ledger — something meant that nothing can currently prove. { action:"list" } returns what is still open. Stored in .reticle/intent/<subject>/intent.json (a flow name is its subject), git-checked so a human sees in review if an intent was later narrowed to match what was easy to prove.',
40
+ description: 'Record what a change is SUPPOSED to make true, as a durable statement ABOUT THE PRODUCT that a teammate who was not here will understand in six months — name the behaviour, not this run or its step number, and never "renders cleanly", which nothing can check. It is SHARED memory: pooled per project and read back by later agents. Capture it while you still know — then verification does not have to re-derive it from the DOM later. { action:"declare", intents:[{ id, statement, surface? }] } takes prose and needs NO predicate: at declare time there is often no route, no ref and no code yet, and a predicate demanded there is just a mechanism. Declare EARLY (as you build) and batch them — one call per feature is the whole budget. { action:"bind", id, binding } attaches the predicate that would prove it once you know how; an intent with no binding is not a failure, it is the most interesting row in the ledger — something meant that nothing can currently prove. { action:"list" } returns what is still open. Pass `request` (what the user asked, verbatim) once per task. Stored in .reticle/intent/, git-checked.',
36
41
  example: {
37
42
  action: DECLARE,
38
43
  intents: [
@@ -74,6 +79,7 @@ export const INTENT_TOOLS = [
74
79
  .min(1)
75
80
  .optional()
76
81
  .describe('declare only. Batchable — declare every intent for a feature in one call.'),
82
+ request: z.string().optional().describe("declare: the user's words, verbatim."),
77
83
  id: z.string().optional().describe('bind only: which intent the predicate proves.'),
78
84
  /*
79
85
  * The predicate schema, not `unknown`.
@@ -155,6 +161,12 @@ export const INTENT_TOOLS = [
155
161
  return { intents: await store.open() };
156
162
  }
157
163
  const raw = args['intents'];
164
+ const request = asString(args['request']);
165
+ if (request !== undefined && 0 < request.trim().length) {
166
+ await recordRequest(deps, root, request, Array.isArray(raw) ? raw : []);
167
+ if (!Array.isArray(raw) || 0 === raw.length)
168
+ return { request: 'recorded', path: root };
169
+ }
158
170
  // Refused, not read as an empty list: `declare` with nothing to declare stored nothing and
159
171
  // answered `{ intents: [] }`, which an agent reads as a declaration that worked (#1118).
160
172
  if (!Array.isArray(raw) || 0 === raw.length) {
@@ -171,3 +183,69 @@ export const INTENT_TOOLS = [
171
183
  },
172
184
  },
173
185
  ];
186
+ const MAX_REQUEST = 20_000;
187
+ const MAX_STATEMENT = 500;
188
+ const MAX_STATEMENTS = 100;
189
+ const PROJECT_CONFIG = '.reticle.json';
190
+ /** Credentials and personal data out, length capped: the same rules feedback reports follow. */
191
+ function redactSecrets(text, max) {
192
+ return redactFeedbackText(text, max).text;
193
+ }
194
+ /**
195
+ * Whether the request goes to the platform with the runs that verify it.
196
+ *
197
+ * A LINKED project shares it unless `.reticle.json` says `"shareRequests": false`: the platform
198
+ * reads what each run was for, which is half of what a run means. An unlinked project has nowhere
199
+ * to send it, and `"shareRequests": true` still shares from one that links later.
200
+ */
201
+ function sharesRequests(projectDir, reticleRoot) {
202
+ let setting;
203
+ try {
204
+ const config = JSON.parse(readFileSync(join(projectDir, PROJECT_CONFIG), 'utf8'));
205
+ setting = config?.shareRequests;
206
+ }
207
+ catch {
208
+ setting = undefined;
209
+ }
210
+ if (false === setting)
211
+ return false;
212
+ if (true === setting)
213
+ return true;
214
+ // Linked by `reticle connect` (cloud.json), or by a key in the environment, the way CI syncs.
215
+ return (existsSync(join(reticleRoot, ReticleDir.CLOUD_LINK_FILE)) ||
216
+ apiKeyFrom(process.env) !== undefined);
217
+ }
218
+ /**
219
+ * Keep the user's request, and every statement in it and in the declared intents, classified and
220
+ * attributed, for the runs that verify it. Secrets are redacted before anything is written.
221
+ *
222
+ * Shared with the platform from a linked project unless `.reticle.json` says `"shareRequests": false`.
223
+ */
224
+ async function recordRequest(deps, root, request, declared) {
225
+ const text = redactSecrets(request, MAX_REQUEST);
226
+ const agentStatements = declared.flatMap((d) => {
227
+ const statement = d?.statement;
228
+ return 'string' === typeof statement ? [statement] : [];
229
+ });
230
+ const context = {
231
+ request: text,
232
+ statements: [
233
+ ...splitStatements(text).map((t) => ({ text: t, source: StatementSource.USER })),
234
+ ...agentStatements.map((t) => ({
235
+ text: redactSecrets(t, MAX_STATEMENT),
236
+ source: StatementSource.AGENT,
237
+ })),
238
+ ]
239
+ .slice(0, MAX_STATEMENTS)
240
+ .map((s) => ({
241
+ ...s,
242
+ text: s.text.slice(0, MAX_STATEMENT),
243
+ kind: classifyStatement(s.text),
244
+ })),
245
+ at: deps.now(),
246
+ shared: sharesRequests(dirname(root), root),
247
+ };
248
+ const path = reticleDirPaths(root).request;
249
+ await deps.fs.mkdir(dirname(path));
250
+ await deps.fs.writeFile(path, `${JSON.stringify(context, null, 2)}\n`);
251
+ }
@@ -113,13 +113,13 @@ export declare const RouteEnvelopeSchema: z.ZodObject<{
113
113
  m2: number;
114
114
  max: number;
115
115
  };
116
- netErrors: {
116
+ consoleErrors: {
117
117
  count: number;
118
118
  mean: number;
119
119
  m2: number;
120
120
  max: number;
121
121
  };
122
- consoleErrors: {
122
+ netErrors: {
123
123
  count: number;
124
124
  mean: number;
125
125
  m2: number;
@@ -138,13 +138,13 @@ export declare const RouteEnvelopeSchema: z.ZodObject<{
138
138
  m2: number;
139
139
  max: number;
140
140
  };
141
- netErrors: {
141
+ consoleErrors: {
142
142
  count: number;
143
143
  mean: number;
144
144
  m2: number;
145
145
  max: number;
146
146
  };
147
- consoleErrors: {
147
+ netErrors: {
148
148
  count: number;
149
149
  mean: number;
150
150
  m2: number;
@@ -167,13 +167,13 @@ export declare const RouteEnvelopeSchema: z.ZodObject<{
167
167
  m2: number;
168
168
  max: number;
169
169
  };
170
- netErrors: {
170
+ consoleErrors: {
171
171
  count: number;
172
172
  mean: number;
173
173
  m2: number;
174
174
  max: number;
175
175
  };
176
- consoleErrors: {
176
+ netErrors: {
177
177
  count: number;
178
178
  mean: number;
179
179
  m2: number;
@@ -196,13 +196,13 @@ export declare const RouteEnvelopeSchema: z.ZodObject<{
196
196
  m2: number;
197
197
  max: number;
198
198
  };
199
- netErrors: {
199
+ consoleErrors: {
200
200
  count: number;
201
201
  mean: number;
202
202
  m2: number;
203
203
  max: number;
204
204
  };
205
- consoleErrors: {
205
+ netErrors: {
206
206
  count: number;
207
207
  mean: number;
208
208
  m2: number;
@@ -1,5 +1,6 @@
1
1
  import { EventAttribution, JOURNAL_FILE_VERSION, } from '@reticlehq/core';
2
2
  import { isSelfObservation } from './self-observation.js';
3
+ import { currentDrivenBy } from '../../hooks/driven-by.js';
3
4
  import { log } from '../../log.js';
4
5
  /**
5
6
  * Logged the FIRST time a journal write is lost, and never again for that recorder.
@@ -65,7 +66,7 @@ export class JournalRecorder {
65
66
  }
66
67
  /** Open an attribution window. One action is active at a time (the agent drives sequentially). */
67
68
  beginAction(actionId, tool, args) {
68
- this.#active = { actionId, tool, args, tStart: this.#now() };
69
+ this.#active = { actionId, tool, args, tStart: this.#now(), drivenBy: currentDrivenBy() };
69
70
  }
70
71
  /** Close the active window: persist its buffered events, then the action record. No-op if none active. */
71
72
  finishAction(effect, settled, settledInMs) {
@@ -86,6 +87,7 @@ export class JournalRecorder {
86
87
  : { from: active.seqFrom, to: active.seqTo },
87
88
  tRange: { from: active.tStart, to: this.#now() },
88
89
  at: active.tStart,
90
+ ...(active.drivenBy === undefined ? {} : { drivenBy: active.drivenBy }),
89
91
  };
90
92
  this.#enqueueFlush();
91
93
  this.#chain = this.#chain
@@ -123,6 +125,10 @@ export class JournalRecorder {
123
125
  // of zero, which is exactly how much time this action attributed events over.
124
126
  tRange: { from: at, to: at },
125
127
  at,
128
+ ...(() => {
129
+ const by = currentDrivenBy();
130
+ return by === undefined ? {} : { drivenBy: by };
131
+ })(),
126
132
  };
127
133
  this.#enqueueFlush();
128
134
  this.#chain = this.#chain
@@ -11,6 +11,8 @@ export declare const DEFAULT_DIFF_RETENTION = 10;
11
11
  * for the one case where delivery was refused and somebody has to file the report by hand.
12
12
  */
13
13
  export declare const DEFAULT_FEEDBACK_RETENTION = 20;
14
+ /** Harness plans kept on disk: the platform keeps every one, this machine the recent ones. */
15
+ export declare const DEFAULT_PLAN_RETENTION = 50;
14
16
  /**
15
17
  * The TOTAL the evidence tier of one `.reticle/` may occupy, across sessions, runs, overlay diffs
16
18
  * and feedback copies together.
@@ -141,5 +143,6 @@ export declare function pruneEvidenceBudget(fs: FileSystemPort, root: string, bu
141
143
  * Capped from here rather than from the writer, which lives in an area this file does not own. A
142
144
  * retention rule is the same rule whether the directory holds journals, diffs or reports.
143
145
  */
146
+ export declare function prunePlans(fs: FileSystemPort, root: string, retention?: number): Promise<void>;
144
147
  export declare function pruneFeedback(fs: FileSystemPort, root: string, retention?: number): Promise<void>;
145
148
  export {};
@@ -14,6 +14,8 @@ export const DEFAULT_DIFF_RETENTION = 10;
14
14
  * for the one case where delivery was refused and somebody has to file the report by hand.
15
15
  */
16
16
  export const DEFAULT_FEEDBACK_RETENTION = 20;
17
+ /** Harness plans kept on disk: the platform keeps every one, this machine the recent ones. */
18
+ export const DEFAULT_PLAN_RETENTION = 50;
17
19
  const DIFF_SUFFIX = '.diff.png';
18
20
  const PNG_SUFFIX = '.png';
19
21
  /**
@@ -287,6 +289,9 @@ export async function pruneEvidenceBudget(fs, root, budgetBytes = DEFAULT_EVIDEN
287
289
  * Capped from here rather than from the writer, which lives in an area this file does not own. A
288
290
  * retention rule is the same rule whether the directory holds journals, diffs or reports.
289
291
  */
292
+ export async function prunePlans(fs, root, retention = DEFAULT_PLAN_RETENTION) {
293
+ await pruneByRecency(fs, join(root, ReticleDir.PLANS_SUBDIR), retention);
294
+ }
290
295
  export async function pruneFeedback(fs, root, retention = DEFAULT_FEEDBACK_RETENTION) {
291
296
  await pruneByRecency(fs, join(root, ReticleDir.FEEDBACK_SUBDIR), retention);
292
297
  }
@@ -1,4 +1,4 @@
1
- import { pruneEvidenceBudget, pruneFeedback, pruneSessions, pruneVisualDiffs, } from './retention.js';
1
+ import { pruneEvidenceBudget, pruneFeedback, prunePlans, pruneSessions, pruneVisualDiffs, } from './retention.js';
2
2
  import { DEFAULT_RETAIN } from './retain-policy.js';
3
3
  export async function pruneWorkspace(fs, root, live, options = {}) {
4
4
  const retain = { ...DEFAULT_RETAIN, ...options };
@@ -9,6 +9,8 @@ export async function pruneWorkspace(fs, root, live, options = {}) {
9
9
  await pruneVisualDiffs(fs, root, retain.visual);
10
10
  // Write-only local copies of reports the outbox already carries.
11
11
  await pruneFeedback(fs, root, retain.feedback);
12
+ // The recent plans are what somebody reads back; the platform holds every one.
13
+ await prunePlans(fs, root);
12
14
  // LAST, and the one that was missing: the only bound that limits SIZE rather than count.
13
15
  await pruneEvidenceBudget(fs, root, retain.budgetBytes, live);
14
16
  }
@@ -45,6 +45,8 @@ export const WORKSPACE_TIERS = {
45
45
  [ReticleDir.VISUAL_SUBDIR]: localDir,
46
46
  // Write-only: a local copy of what was already sent. The outbox is the record.
47
47
  [ReticleDir.FEEDBACK_SUBDIR]: localDir,
48
+ // What the Harness planned on this machine, and how it went. The platform keeps the team's copy.
49
+ [ReticleDir.PLANS_SUBDIR]: localDir,
48
50
  [ReticleDir.PROJECT_FILE]: localFile,
49
51
  [ReticleDir.AMBIENT_FILE]: localFile,
50
52
  [ReticleDir.ENVELOPES_FILE]: localFile,
@@ -52,6 +54,11 @@ export const WORKSPACE_TIERS = {
52
54
  // What THIS machine's drives covered, and the best each level reached — the gate's ratchet.
53
55
  [ReticleDir.COVERAGE_FILE]: localFile,
54
56
  [ReticleDir.TIERS_FILE]: localFile,
57
+ // The user's request in their own words: it leaves only with `shareRequests`, never by git push.
58
+ [ReticleDir.REQUEST_FILE]: localFile,
59
+ [ReticleDir.PLATFORM_MOMENTS_FILE]: localFile,
60
+ // Notes a human pinned in the HUD: free text about the page. The team reads them on the platform.
61
+ [ReticleDir.NOTES_FILE]: localFile,
55
62
  // The user's own record of what Reticle did for them, on THIS machine.
56
63
  [ReticleDir.IMPACT_FILE]: localFile,
57
64
  // This machine's conversation with the server. Its own doc comment says why committing it is
@@ -72,10 +79,6 @@ export const WORKSPACE_TIERS = {
72
79
  // credential, so it is safe to commit — the same reasoning that puts `cloud.json` here while its
73
80
  // API key stays in ~/.reticle.
74
81
  [ReticleDir.HOOKS_FILE]: sharedFile,
75
- // What a drive types into each labelled field. Committed for two reasons: a replay must send
76
- // exactly what the recording sent or it is not a replay, and a value a model wrote once should be
77
- // reviewable and editable by the team rather than regenerated differently on every machine.
78
- [ReticleDir.FILL_VALUES_FILE]: sharedFile,
79
82
  };
80
83
  function namesWhere(match) {
81
84
  return Object.entries(WORKSPACE_TIERS)
@@ -13,7 +13,7 @@ import { AmbientStore } from './ambient-store.js';
13
13
  import { subjectOf } from '@reticlehq/core';
14
14
  import { pruneWorkspace } from './on-disk/startup-maintenance.js';
15
15
  import { buildVerificationRun } from '../../judgement/runs/artifact/build-verification-run.js';
16
- import { driveRunFrom, driveRunId } from '../../judgement/runs/drive-run.js';
16
+ import { driveRunId, driveRunsFrom } from '../../judgement/runs/drive-run.js';
17
17
  import { RunStore } from '../../judgement/runs/artifact/run-store.js';
18
18
  /**
19
19
  * Build the teardown handler the bridge fires when a session is removed. Flushes the journal first (so no
@@ -173,7 +173,7 @@ export async function recordDriveRun(deps, session) {
173
173
  if (session.readJournalActions === undefined)
174
174
  return;
175
175
  const actions = await session.readJournalActions();
176
- const input = driveRunFrom(actions, {
176
+ const inputs = driveRunsFrom(actions, {
177
177
  // Derived from the session, NOT random. Teardown fires on every socket close, and a reconnecting
178
178
  // tab keeps its session id and appends to the same ledger — so a drive across two page reloads
179
179
  // would fold the whole ledger twice and publish two overlapping rows, the second a superset of
@@ -182,7 +182,7 @@ export async function recordDriveRun(deps, session) {
182
182
  runId: driveRunId(session.id),
183
183
  ...(session.projectId === undefined ? {} : { projectId: session.projectId }),
184
184
  });
185
- if (input === undefined)
185
+ if (0 === inputs.length)
186
186
  return;
187
187
  // The protocol reaching the artifact a person actually reads. Only when the session could say
188
188
  // where it was: a subject with no locator is not a weaker subject, it is a guess, and the
@@ -201,7 +201,9 @@ export async function recordDriveRun(deps, session) {
201
201
  // used to write this artifact with no callback at all, so the evidence from a whole session sat
202
202
  // on disk until a timer noticed — which, for the last session of the day, could be never.
203
203
  deps.onRunPersisted === undefined ? undefined : { onWrote: deps.onRunPersisted });
204
- await store.write(buildVerificationRun({ ...input, ...(subject === undefined ? {} : { subject }) }, deps.now ?? (() => Date.now())));
204
+ for (const input of inputs) {
205
+ await store.write(buildVerificationRun({ ...input, ...(subject === undefined ? {} : { subject }) }, deps.now ?? (() => Date.now())));
206
+ }
205
207
  }
206
208
  /** Persist what this session drove as a replayable flow, when it declared anything provable. */
207
209
  async function saveDrivenFlow(deps, session) {
@@ -1,5 +1,16 @@
1
+ import { type ProjectCandidate } from '@reticlehq/core/artifacts';
1
2
  import { type ProjectId } from '@reticlehq/core';
2
3
  import { type ArtifactRoot } from './artifact-root.js';
4
+ /**
5
+ * Every project this machine knows the directory of: discovered `.reticle.json` files first, then
6
+ * the user-level registry, then the dev servers the build plugin announced. Read on each call, since
7
+ * `init` can run in another terminal while the daemon is up.
8
+ *
9
+ * Exported because sync must cover exactly the projects runs are written into. It kept its own list
10
+ * without the announced dev servers, so a project known only through its running dev server had its
11
+ * runs written to its `.reticle/runs/` and never sent, with nothing reporting a problem.
12
+ */
13
+ export declare function knownProjectCandidates(): ProjectCandidate[];
3
14
  /**
4
15
  * The directory of the project a page announced, or undefined when this machine does not know it.
5
16
  *
@@ -10,4 +21,18 @@ import { type ArtifactRoot } from './artifact-root.js';
10
21
  * own project id.
11
22
  */
12
23
  export declare function projectDirectoryFor(projectId: string | undefined): string | undefined;
13
- export declare function artifactRootResolver(daemonRoot: string): (projectId: ProjectId | undefined, origin?: string) => ArtifactRoot;
24
+ /** Where this resolver gets its IO answers. The defaults are the production ones. */
25
+ export interface ArtifactRootResolverDeps {
26
+ /** Every project this machine knows about. Defaults to discovery + the user registry. */
27
+ candidates?: () => ProjectCandidate[];
28
+ /** Whether the daemon's own directory is a Reticle project. Defaults to looking at disk. */
29
+ daemonIsProject?: (daemonRoot: string) => boolean;
30
+ /**
31
+ * The directory of the dev server that served a page origin, or undefined when unobservable.
32
+ * Required, not defaulted: the production answer reads daemon state and shells out, which is the
33
+ * wiring layer's business (`portal/session/serving-directory.ts`), and a resolver built without it
34
+ * would silently keep sending every worktree's artifacts to the primary checkout.
35
+ */
36
+ servingDirectory: (origin: string) => string | undefined;
37
+ }
38
+ export declare function artifactRootResolver(daemonRoot: string, deps: ArtifactRootResolverDeps): (projectId: ProjectId | undefined, origin?: string) => ArtifactRoot;
@@ -7,12 +7,21 @@
7
7
  *
8
8
  * It lives here rather than in the daemon bootstrap because it is a subject, not wiring — and
9
9
  * because the bootstrap is at its line cap, which is the cohesion signal that rule doing its job.
10
+ *
11
+ * ## Why `artifactRootResolver` takes a `deps`
12
+ *
13
+ * Both of its IO answers come from the REAL disk and the REAL `process.cwd()`: which projects this
14
+ * machine knows, and whether the daemon's own directory declares one. That left the file untestable
15
+ * at the level where its defect actually lives — the `unmatchedRoot` specs next door hand a
16
+ * `daemonProjectId` straight in, so a mistake in READING it would leave every one of them green
17
+ * while the worktree bug reproduced unchanged. The defaults are the production answers; the seam is
18
+ * what lets a test say "these are the projects on this machine" without one.
10
19
  */
11
- import { existsSync, readFileSync } from 'node:fs';
20
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
12
21
  import { dirname, join } from 'node:path';
13
22
  import { homedir } from 'node:os';
14
23
  import { PROJECT_REGISTRY_FILE, emptyProjectRegistry, parseProjectRegistry, } from '@reticlehq/core/artifacts';
15
- import { ReticleDir } from '@reticlehq/core';
24
+ import { DevServerEntrySchema, ReticleDir, ReticleEnv, devServerRegistryPort, } from '@reticlehq/core';
16
25
  import { discoverProjectConfigs, hasProjectConfig, } from '../../command/cli/config/config-discovery.js';
17
26
  import { log } from '../../log.js';
18
27
  import { ArtifactRootReason, projectCandidatesFrom, resolveArtifactRoot, unmatchedRoot, } from './artifact-root.js';
@@ -34,12 +43,35 @@ function daemonSitsInAProject(daemonRoot) {
34
43
  return false;
35
44
  }
36
45
  }
46
+ /**
47
+ * The project a candidate list says lives at the daemon's own directory.
48
+ *
49
+ * Not the same question as `daemonSitsInAProject`: that one asks whether the tree invited Reticle in
50
+ * at all (either mark counts, including a bare `.reticle/` from a version that predates the config
51
+ * file), and this one asks WHICH project it is. The difference is the whole of #1244 — a daemon in
52
+ * repo A serving an app from a git worktree B belongs to A and is being asked about B.
53
+ *
54
+ * Takes the candidates rather than gathering them, so the answer is a function of one list and a test
55
+ * can supply it. Read from that same list the resolver already builds, so there is one answer to
56
+ * "where does this project live" rather than a second `.reticle.json` parser here. A daemon whose
57
+ * root was passed explicitly (`--root`, or an embedder's own `reticleRoot`) is not in that list and
58
+ * answers undefined, which is the safe direction: it cannot prove the named project is its own, so it
59
+ * declines the daemon root and the session goes to the unmatched bucket instead.
60
+ */
61
+ function daemonOwnProjectId(daemonRoot, candidates) {
62
+ const directory = dirname(daemonRoot);
63
+ return candidates.find((candidate) => candidate.directory === directory)?.projectId;
64
+ }
37
65
  /**
38
66
  * Every project this machine knows the directory of: discovered `.reticle.json` files first, then
39
- * the user-level registry. Read on each call, since `init` can run in another terminal while the
40
- * daemon is up.
67
+ * the user-level registry, then the dev servers the build plugin announced. Read on each call, since
68
+ * `init` can run in another terminal while the daemon is up.
69
+ *
70
+ * Exported because sync must cover exactly the projects runs are written into. It kept its own list
71
+ * without the announced dev servers, so a project known only through its running dev server had its
72
+ * runs written to its `.reticle/runs/` and never sent, with nothing reporting a problem.
41
73
  */
42
- function knownProjectCandidates() {
74
+ export function knownProjectCandidates() {
43
75
  let registry = emptyProjectRegistry();
44
76
  try {
45
77
  const path = join(homedir(), ReticleDir.ROOT, PROJECT_REGISTRY_FILE);
@@ -58,7 +90,35 @@ function knownProjectCandidates() {
58
90
  catch {
59
91
  // Same reasoning: a diagnostic search that throws must not take a tool call with it.
60
92
  }
61
- return projectCandidatesFrom(discovery, registry);
93
+ return projectCandidatesFrom(discovery, registry, announcedDevServers());
94
+ }
95
+ /**
96
+ * The dev servers the build plugin announced, with the project id and root each serves. Read from
97
+ * the same state directory the plugin writes to. Unreadable or malformed entries are skipped: a stale
98
+ * announcement only ever names a directory that really was that project.
99
+ */
100
+ function announcedDevServers() {
101
+ const override = process.env[ReticleEnv.STATE_DIR];
102
+ const dir = override !== undefined && 0 < override.length ? override : join(homedir(), ReticleDir.ROOT);
103
+ const servers = [];
104
+ try {
105
+ for (const name of readdirSync(dir)) {
106
+ if (null === devServerRegistryPort(name))
107
+ continue;
108
+ try {
109
+ const parsed = DevServerEntrySchema.safeParse(JSON.parse(readFileSync(join(dir, name), 'utf8')));
110
+ if (parsed.success)
111
+ servers.push({ projectId: parsed.data.projectId, root: parsed.data.root });
112
+ }
113
+ catch {
114
+ /* one bad announcement does not hide the others */
115
+ }
116
+ }
117
+ }
118
+ catch {
119
+ /* no state directory yet: nothing has been announced */
120
+ }
121
+ return servers;
62
122
  }
63
123
  /**
64
124
  * The directory of the project a page announced, or undefined when this machine does not know it.
@@ -79,14 +139,31 @@ export function projectDirectoryFor(projectId) {
79
139
  // them would read the wrong app's package.json and prescribe its packages to the other.
80
140
  return 1 === directories.size ? [...directories][0] : undefined;
81
141
  }
82
- export function artifactRootResolver(daemonRoot) {
83
- const daemonIsProject = daemonSitsInAProject(daemonRoot);
142
+ export function artifactRootResolver(daemonRoot, deps) {
143
+ const candidates = deps.candidates ?? knownProjectCandidates;
144
+ const sitsInAProject = deps.daemonIsProject ?? daemonSitsInAProject;
145
+ const { servingDirectory } = deps;
146
+ const daemonIsProject = sitsInAProject(daemonRoot);
147
+ // Resolved beside it and ONCE, for the same reason: a directory's `.reticle.json` does not change
148
+ // between two tabs connecting. Only read when the daemon really is in a project, since it is the
149
+ // sole thing that can license a write into the daemon's own tree.
150
+ const daemonProjectId = daemonIsProject
151
+ ? daemonOwnProjectId(daemonRoot, candidates())
152
+ : undefined;
84
153
  return (projectId, origin) => {
85
- const resolved = resolveArtifactRoot({
86
- projectId,
87
- candidates: knownProjectCandidates(),
88
- daemonRoot,
89
- });
154
+ const known = candidates();
155
+ let resolved = resolveArtifactRoot({ projectId, candidates: known, daemonRoot });
156
+ // Several checkouts declare this project — every git worktree carries the same committed
157
+ // `.reticle.json`. Only now is the serving process looked up: it shells out, and the tie is the
158
+ // one case where where the page was SERVED from is the deciding fact.
159
+ if (resolved.reason === ArtifactRootReason.AMBIGUOUS && origin !== undefined) {
160
+ resolved = resolveArtifactRoot({
161
+ projectId,
162
+ candidates: known,
163
+ daemonRoot,
164
+ servingDirectory: servingDirectory(origin),
165
+ });
166
+ }
90
167
  if (resolved.reason === ArtifactRootReason.MATCHED_PROJECT)
91
168
  return resolved;
92
169
  // Could not name the project. The old code wrote into the daemon's directory anyway and said
@@ -98,6 +175,7 @@ export function artifactRootResolver(daemonRoot) {
98
175
  daemonRoot,
99
176
  daemonIsProject,
100
177
  home: homedir(),
178
+ ...(daemonProjectId === undefined ? {} : { daemonProjectId }),
101
179
  ...(projectId === undefined ? {} : { projectId }),
102
180
  // Only reached when no project id survives the guard. Without it every app that never
103
181
  // stamped one shares a single directory -- and that directory holds the durable half, so one
@@ -107,6 +185,9 @@ export function artifactRootResolver(daemonRoot) {
107
185
  if (root !== daemonRoot) {
108
186
  log('artifact_root_unmatched', {
109
187
  projectId: projectId ?? null,
188
+ // The other half of the comparison, so a reader can tell "the daemon was a guest" from
189
+ // "the daemon is in a DIFFERENT project" without reading the config files by hand.
190
+ daemonProjectId: daemonProjectId ?? null,
110
191
  reason: resolved.reason,
111
192
  root,
112
193
  });
@@ -43,6 +43,13 @@ interface ArtifactRootQuery {
43
43
  candidates: readonly ProjectCandidate[];
44
44
  /** Where artifacts go when the project cannot be identified. Already a `.reticle` path. */
45
45
  daemonRoot: string;
46
+ /**
47
+ * The directory the dev server that served this page runs in, when the caller could observe it.
48
+ * Only consulted to break an AMBIGUOUS tie: every git worktree carries the same committed
49
+ * `.reticle.json`, so the projectId cannot tell them apart, but the serving process runs inside
50
+ * exactly one of them.
51
+ */
52
+ servingDirectory?: string | undefined;
46
53
  }
47
54
  export interface ArtifactRoot {
48
55
  /** Absolute path to the `.reticle` directory. Always present, on every branch. */
@@ -68,7 +75,10 @@ export declare function resolveArtifactRoot(query: ArtifactRootQuery): ArtifactR
68
75
  * is a cache of something `init` saw once. Order only decides which duplicate survives dedupe, and
69
76
  * the one confirmed a moment ago is the better survivor.
70
77
  */
71
- export declare function projectCandidatesFrom(discovery: ConfigDiscovery, registry: ProjectRegistry): ProjectCandidate[];
78
+ export declare function projectCandidatesFrom(discovery: ConfigDiscovery, registry: ProjectRegistry, devServers?: readonly {
79
+ projectId?: string | undefined;
80
+ root: string;
81
+ }[]): ProjectCandidate[];
72
82
  /** Where evidence goes when no project could be named and the daemon is a guest in this tree. */
73
83
  export declare const UNMATCHED_SUBDIR = "unmatched";
74
84
  /**
@@ -83,20 +93,47 @@ export declare const UNMATCHED_SUBDIR = "unmatched";
83
93
  * Two cases, and only one of them was ever the intended behaviour:
84
94
  *
85
95
  * - the daemon is sitting IN a Reticle project (a developer who ran `reticle serve` in their own
86
- * app). Its root is the right answer, and it is the case the old fallback was written for.
87
- * - the daemon is a guest — no `.reticle.json`, no `.reticle/` already there. Then the evidence
88
- * goes to the user's own `~/.reticle/unmatched/<projectId>`, which is Reticle's to write.
96
+ * app), and the session is asking about THAT project. Its root is the right answer, and it is
97
+ * the case the old fallback was written for.
98
+ * - otherwise — a guest in the tree, or a daemon in its own checkout serving a DIFFERENT project.
99
+ * Then the evidence goes to the user's own `~/.reticle/unmatched/<projectId>`, which is
100
+ * Reticle's to write.
89
101
  *
90
102
  * It is never dropped. A verdict with nowhere to live is a worse failure than one in an unexpected
91
103
  * place, and the caller says out loud where it went.
92
104
  *
105
+ * ## The second case in that list used to be missing, and it is not an edge case
106
+ *
107
+ * `daemonIsProject` answers "is the daemon sitting in SOME Reticle project". The guard below read it
108
+ * as "this session's project", and those are different questions whenever the two ids differ. Run
109
+ * the daemon in repo A, then connect an app from a git worktree B of that same repo: B has its own
110
+ * `projectId` in its own `.reticle.json`, and discovery cannot see it — it walks out from A's
111
+ * `process.cwd()` and never crosses into a sibling checkout — so this function is reached with a
112
+ * NAMED project and the daemon's root came back anyway. B's intents, flows and runs were written
113
+ * into A's checkout, and the returned path said A, so nothing looked wrong.
114
+ *
115
+ * A real identity always wins. The daemon's root is only the answer when it is that identity's own
116
+ * root, or when no identity was named at all.
117
+ *
93
118
  * `projectId` arrives in HELLO from the page, so it is untrusted input on a path join and is held
94
- * to one safe segment — the same guard session ids and flow names already pass.
119
+ * to one safe segment — the same guard session ids and flow names already pass. `daemonProjectId`
120
+ * is read from the daemon's own `.reticle.json` and gets the same treatment, because equality
121
+ * against a guarded value is only as strong as the weaker side.
95
122
  */
96
123
  export declare function unmatchedRoot(query: {
97
124
  daemonRoot: string;
98
125
  /** Whether the daemon's own directory is a Reticle project — an IO question, answered by the caller. */
99
126
  daemonIsProject: boolean;
127
+ /**
128
+ * The daemon's OWN projectId, when its directory declares one. Absent means the daemon cannot
129
+ * prove which project it belongs to, so a named project that is not demonstrably its own declines.
130
+ *
131
+ * A plain `string` rather than a `ProjectId`, because it arrives from a `ProjectCandidate` — the
132
+ * registry's view of a `.reticle.json` — and the brand is only minted where the id is read out of
133
+ * that file for this daemon. Comparing it against a branded session id is unaffected: the brand is
134
+ * a `string`, and the guard below is what actually holds it to one safe path segment.
135
+ */
136
+ daemonProjectId?: string | undefined;
100
137
  /** The user's home directory. Passed in rather than read, so this stays pure. */
101
138
  home: string;
102
139
  projectId?: ProjectId | undefined;