@reticlehq/server 3.1.0 → 3.2.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 (228) hide show
  1. package/SKILL.md +17 -18
  2. package/dist/command/cli/auth/device-flow.d.ts +2 -2
  3. package/dist/command/cli/cli-doctor.js +13 -1
  4. package/dist/command/cli/cli-flow-commands.d.ts +2 -1
  5. package/dist/command/cli/cli-kill.js +1 -1
  6. package/dist/command/cli/cli-parse.d.ts +2 -1
  7. package/dist/command/cli/cli-parse.js +5 -2
  8. package/dist/command/cli/cli-verify.js +2 -2
  9. package/dist/command/cli/cloud-cli.js +7 -6
  10. package/dist/command/cli/cloud-kit.js +2 -6
  11. package/dist/command/cli/config/config-discovery.d.ts +12 -0
  12. package/dist/command/cli/config/config-discovery.js +19 -0
  13. package/dist/command/cli/doctor/doctor-rows.d.ts +12 -0
  14. package/dist/command/cli/doctor/doctor-rows.js +12 -0
  15. package/dist/command/cli/drive/drive-command.js +1 -1
  16. package/dist/command/cli/launch/cli-launch.d.ts +10 -2
  17. package/dist/command/cli/launch/cli-launch.js +32 -39
  18. package/dist/command/cli/lifecycle/daemon-lifecycle.d.ts +33 -0
  19. package/dist/command/cli/lifecycle/daemon-lifecycle.js +198 -0
  20. package/dist/command/cli/mcp-command.js +5 -3
  21. package/dist/command/cli/ports/resolve/cli-port.d.ts +2 -1
  22. package/dist/command/cli/ports/resolve/cli-port.js +5 -1
  23. package/dist/command/cli/status/status-lines.d.ts +28 -0
  24. package/dist/command/cli/status/status-lines.js +117 -0
  25. package/dist/command/cli.d.ts +1 -1
  26. package/dist/command/cli.js +24 -177
  27. package/dist/command/daemon/binding/daemon-status-probe.d.ts +2 -0
  28. package/dist/command/daemon/binding/daemon-status-probe.js +48 -0
  29. package/dist/command/daemon/binding/loopback-reach.d.ts +42 -0
  30. package/dist/command/daemon/binding/loopback-reach.js +63 -0
  31. package/dist/command/daemon/daemon-resilience.d.ts +13 -1
  32. package/dist/command/daemon/daemon-resilience.js +12 -1
  33. package/dist/command/daemon/daemon.d.ts +2 -11
  34. package/dist/command/daemon/daemon.js +8 -11
  35. package/dist/command/daemon/lifetime/heartbeat.d.ts +8 -0
  36. package/dist/command/daemon/lifetime/heartbeat.js +10 -0
  37. package/dist/command/daemon/lifetime/log-cap.d.ts +68 -0
  38. package/dist/command/daemon/lifetime/log-cap.js +115 -0
  39. package/dist/command/setup/agent-notice-memory.d.ts +16 -0
  40. package/dist/command/setup/agent-notice-memory.js +65 -0
  41. package/dist/command/setup/bringup/dev-server-wait.d.ts +1 -0
  42. package/dist/command/setup/bringup/dev-server-wait.js +16 -0
  43. package/dist/command/setup/bringup/ensure-daemon.js +1 -1
  44. package/dist/command/setup/init/init-runtime.js +13 -1
  45. package/dist/command/setup/node-effects.js +1 -1
  46. package/dist/command/setup/probe/page-probe.js +9 -2
  47. package/dist/command/setup/run-setup.d.ts +25 -7
  48. package/dist/command/setup/run-setup.js +51 -9
  49. package/dist/command/setup/setup-command.js +31 -3
  50. package/dist/command/setup/terminal/confirm.js +2 -1
  51. package/dist/command/update/updater.d.ts +27 -0
  52. package/dist/command/update/updater.js +35 -2
  53. package/dist/features/crawl/crawl-tools.js +3 -1
  54. package/dist/features/crawl/crawl.js +2 -1
  55. package/dist/features/harness/drive-report.d.ts +73 -0
  56. package/dist/features/harness/drive-report.js +196 -0
  57. package/dist/features/harness/driver.js +29 -8
  58. package/dist/features/harness/drivers.d.ts +19 -0
  59. package/dist/features/harness/drivers.js +19 -0
  60. package/dist/features/harness/fill-values.d.ts +67 -0
  61. package/dist/features/harness/fill-values.js +153 -0
  62. package/dist/features/harness/jev-driver.d.ts +188 -0
  63. package/dist/features/harness/jev-driver.js +664 -0
  64. package/dist/features/harness/openai-driver.d.ts +46 -0
  65. package/dist/features/harness/openai-driver.js +197 -0
  66. package/dist/features/harness/platform-config.d.ts +52 -0
  67. package/dist/features/harness/platform-config.js +89 -0
  68. package/dist/features/visual/visual-tools.js +26 -2
  69. package/dist/index.js +60 -55
  70. package/dist/judgement/domain/domain-tools.js +4 -2
  71. package/dist/judgement/runs/verification-sync.d.ts +2 -2
  72. package/dist/judgement/runs/verification-sync.js +9 -2
  73. package/dist/language/flows/affected-tools.js +6 -3
  74. package/dist/language/flows/flow-replay-run.d.ts +25 -2
  75. package/dist/language/flows/flow-replay-run.js +73 -27
  76. package/dist/language/flows/flow-replay.d.ts +17 -0
  77. package/dist/language/flows/flow-replay.js +148 -100
  78. package/dist/language/flows/flow-result.d.ts +18 -1
  79. package/dist/language/flows/flow-result.js +18 -2
  80. package/dist/language/flows/flow-store-for-session.d.ts +2 -1
  81. package/dist/language/flows/flow-tools.js +8 -4
  82. package/dist/language/flows/flows.d.ts +8 -7
  83. package/dist/language/flows/flows.js +7 -3
  84. package/dist/language/flows/narrow-write.d.ts +4 -4
  85. package/dist/language/flows/replay.js +1 -1
  86. package/dist/language/flows/suite-selection.d.ts +15 -1
  87. package/dist/language/flows/suite-selection.js +1 -1
  88. package/dist/language/flows/verify-change-tools.js +7 -4
  89. package/dist/memory/cloud/account-state.d.ts +1 -1
  90. package/dist/memory/cloud/account-state.js +5 -4
  91. package/dist/memory/cloud/cloud-config.d.ts +9 -0
  92. package/dist/memory/cloud/cloud-config.js +9 -0
  93. package/dist/memory/cloud/cloud-sync.d.ts +7 -2
  94. package/dist/memory/cloud/cloud-sync.js +10 -5
  95. package/dist/memory/cloud/harness-config.d.ts +10 -0
  96. package/dist/memory/cloud/harness-config.js +58 -0
  97. package/dist/memory/cloud/harness-offer.d.ts +44 -0
  98. package/dist/memory/cloud/harness-offer.js +146 -0
  99. package/dist/memory/cloud/harness-switch.d.ts +11 -0
  100. package/dist/memory/cloud/harness-switch.js +53 -0
  101. package/dist/memory/cloud/sync-daemon.d.ts +22 -2
  102. package/dist/memory/cloud/sync-daemon.js +24 -0
  103. package/dist/memory/impact/impact-recorder.d.ts +8 -0
  104. package/dist/memory/impact/impact-recorder.js +2 -1
  105. package/dist/memory/impact/impact-store.d.ts +5 -0
  106. package/dist/memory/impact/impact-store.js +19 -0
  107. package/dist/memory/intent/intent-shard-store.js +2 -2
  108. package/dist/memory/intent/intent-shard.d.ts +12 -12
  109. package/dist/memory/journal/attach-journal.d.ts +11 -0
  110. package/dist/memory/journal/attach-journal.js +1 -1
  111. package/dist/memory/journal/journal-query.d.ts +35 -1
  112. package/dist/memory/journal/journal-query.js +46 -0
  113. package/dist/memory/journal/journal-recorder.d.ts +50 -2
  114. package/dist/memory/journal/journal-recorder.js +45 -7
  115. package/dist/memory/journal/on-disk/retention.d.ts +87 -2
  116. package/dist/memory/journal/on-disk/retention.js +177 -8
  117. package/dist/memory/journal/on-disk/workspace-gitignore.d.ts +9 -0
  118. package/dist/memory/journal/on-disk/workspace-gitignore.js +9 -2
  119. package/dist/memory/journal/self-observation.d.ts +3 -0
  120. package/dist/memory/journal/self-observation.js +67 -0
  121. package/dist/memory/journal/session-end.d.ts +13 -3
  122. package/dist/memory/journal/session-end.js +24 -4
  123. package/dist/memory/journal/session-journal.d.ts +115 -5
  124. package/dist/memory/journal/session-journal.js +479 -32
  125. package/dist/memory/project/artifact-root-resolver.d.ts +3 -0
  126. package/dist/memory/project/artifact-root-resolver.js +89 -0
  127. package/dist/memory/project/artifact-root.d.ts +39 -2
  128. package/dist/memory/project/artifact-root.js +58 -1
  129. package/dist/memory/project/dir/fill-value-store.d.ts +32 -0
  130. package/dist/memory/project/dir/fill-value-store.js +88 -0
  131. package/dist/memory/project/dir/reticle-dir.d.ts +14 -4
  132. package/dist/memory/project/dir/reticle-dir.js +11 -0
  133. package/dist/memory/project/fs/fs-port.d.ts +22 -1
  134. package/dist/memory/project/fs/fs-port.js +33 -5
  135. package/dist/memory/project/learned-routes.d.ts +16 -2
  136. package/dist/memory/project/learned-routes.js +26 -10
  137. package/dist/memory/project/project-for-root.d.ts +30 -0
  138. package/dist/memory/project/project-for-root.js +43 -0
  139. package/dist/memory/project/project-tools.js +10 -5
  140. package/dist/memory/project/session-root.d.ts +35 -6
  141. package/dist/memory/project/session-root.js +70 -8
  142. package/dist/portal/bridge/auth-failure-reason.d.ts +9 -0
  143. package/dist/portal/bridge/auth-failure-reason.js +15 -1
  144. package/dist/portal/bridge/bridge.d.ts +6 -1
  145. package/dist/portal/bridge/bridge.js +36 -2
  146. package/dist/portal/session/facts/session-replaced.d.ts +19 -0
  147. package/dist/portal/session/facts/session-replaced.js +22 -0
  148. package/dist/portal/session/no-session-diagnosis.d.ts +1 -0
  149. package/dist/portal/session/no-session-diagnosis.js +67 -60
  150. package/dist/portal/session/no-session-next-action.js +3 -2
  151. package/dist/portal/session/no-session-watch.js +14 -10
  152. package/dist/portal/session/rebind.d.ts +18 -0
  153. package/dist/portal/session/rebind.js +38 -0
  154. package/dist/portal/session/session-manager.d.ts +4 -0
  155. package/dist/portal/session/session-manager.js +19 -1
  156. package/dist/portal/session/session.d.ts +21 -10
  157. package/dist/portal/session/session.js +29 -42
  158. package/dist/status-payload.d.ts +12 -1
  159. package/dist/status-payload.js +4 -1
  160. package/dist/surface/http-server.js +3 -5
  161. package/dist/surface/mcp/faults/mcp-outage.d.ts +2 -1
  162. package/dist/surface/mcp/faults/mcp-outage.js +20 -4
  163. package/dist/surface/mcp/mcp-proxy.d.ts +10 -1
  164. package/dist/surface/mcp/mcp-proxy.js +20 -9
  165. package/dist/surface/mcp/mcp.d.ts +10 -1
  166. package/dist/surface/mcp/mcp.js +59 -29
  167. package/dist/surface/mcp/proxy/proxy-daemon-probe.d.ts +15 -2
  168. package/dist/surface/mcp/proxy/proxy-daemon-probe.js +22 -5
  169. package/dist/surface/mcp/proxy/proxy-log.d.ts +5 -11
  170. package/dist/surface/mcp/proxy/proxy-log.js +7 -22
  171. package/dist/surface/tools/act/act-preflight.js +6 -1
  172. package/dist/surface/tools/act/sequence-grade.js +13 -0
  173. package/dist/surface/tools/act-capsule.js +27 -1
  174. package/dist/surface/tools/assert/assert-verdict.js +44 -17
  175. package/dist/surface/tools/coverage-tools.js +5 -2
  176. package/dist/surface/tools/dynamic-tools.js +37 -46
  177. package/dist/surface/tools/error-recovery.d.ts +6 -0
  178. package/dist/surface/tools/error-recovery.js +15 -0
  179. package/dist/surface/tools/explore-tools.js +80 -7
  180. package/dist/surface/tools/feedback-tools.js +49 -2
  181. package/dist/surface/tools/first-sentence.d.ts +24 -0
  182. package/dist/surface/tools/first-sentence.js +0 -0
  183. package/dist/surface/tools/harness-explore.d.ts +125 -1
  184. package/dist/surface/tools/harness-explore.js +401 -15
  185. package/dist/surface/tools/harness-plan.d.ts +76 -0
  186. package/dist/surface/tools/harness-plan.js +156 -0
  187. package/dist/surface/tools/harness-toolset.js +12 -1
  188. package/dist/surface/tools/invoke-tool.js +65 -5
  189. package/dist/surface/tools/live-call-text.d.ts +6 -0
  190. package/dist/surface/tools/live-call-text.js +36 -2
  191. package/dist/surface/tools/observe-tools.js +55 -9
  192. package/dist/surface/tools/read/query-windowed.d.ts +1 -1
  193. package/dist/surface/tools/read/query-windowed.js +1 -1
  194. package/dist/surface/tools/read-tools.js +1 -2
  195. package/dist/surface/tools/tool-kit.d.ts +18 -3
  196. package/dist/surface/tools/tools-helpers.d.ts +8 -7
  197. package/dist/surface/tools/tools-helpers.js +11 -13
  198. package/dist/surface/tools/tools.js +6 -1
  199. package/dist/telemetry/bug-found.js +32 -2
  200. package/dist/telemetry/feedback-context.js +3 -3
  201. package/dist/telemetry/harness-drive.d.ts +6 -0
  202. package/dist/telemetry/harness-drive.js +39 -0
  203. package/dist/telemetry/mcp-connection.d.ts +14 -1
  204. package/dist/telemetry/mcp-connection.js +17 -2
  205. package/dist/telemetry/onboarding-firsts.d.ts +30 -1
  206. package/dist/telemetry/onboarding-firsts.js +54 -12
  207. package/dist/telemetry/verification-of.js +31 -3
  208. package/docs/agent-cheatsheet.md +7 -3
  209. package/docs/autodrive.md +42 -2
  210. package/docs/benchmarks.md +1 -1
  211. package/docs/capabilities.mdx +1 -1
  212. package/docs/cli/cloud.mdx +3 -3
  213. package/docs/cli/doctor.mdx +1 -0
  214. package/docs/cli/status.mdx +35 -20
  215. package/docs/cli.mdx +8 -18
  216. package/docs/desktop.mdx +1 -1
  217. package/docs/faq.mdx +1 -1
  218. package/docs/install-manual.mdx +1 -1
  219. package/docs/packages/tauri.mdx +1 -1
  220. package/docs/packages.mdx +1 -1
  221. package/docs/predicates.mdx +16 -0
  222. package/docs/system-map.md +6 -4
  223. package/docs/telemetry.md +2 -1
  224. package/docs/token-efficiency.md +2 -2
  225. package/docs/tools/overview.mdx +1 -1
  226. package/docs/troubleshooting.mdx +8 -2
  227. package/docs/usage.md +1 -1
  228. package/package.json +6 -6
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Which `.reticle` a connected session's artifacts belong in — the IO half.
3
+ *
4
+ * `resolveArtifactRoot` and `unmatchedRoot` next door are pure on purpose; this is the file that
5
+ * goes to disk for them: the user-level project registry, config discovery, and the one question
6
+ * that decides whether the daemon may write into its own working directory at all.
7
+ *
8
+ * It lives here rather than in the daemon bootstrap because it is a subject, not wiring — and
9
+ * because the bootstrap is at its line cap, which is the cohesion signal that rule doing its job.
10
+ */
11
+ import { existsSync, readFileSync } from 'node:fs';
12
+ import { dirname, join } from 'node:path';
13
+ import { homedir } from 'node:os';
14
+ import { PROJECT_REGISTRY_FILE, emptyProjectRegistry, parseProjectRegistry, } from '@reticlehq/core/artifacts';
15
+ import { ReticleDir } from '@reticlehq/core';
16
+ import { discoverProjectConfigs, hasProjectConfig, } from '../../command/cli/config/config-discovery.js';
17
+ import { log } from '../../log.js';
18
+ import { ArtifactRootReason, projectCandidatesFrom, resolveArtifactRoot, unmatchedRoot, } from './artifact-root.js';
19
+ /**
20
+ * Is the daemon's own directory a Reticle project, or is it a guest there?
21
+ *
22
+ * Answered ONCE per resolver rather than per session: a directory does not become a project between
23
+ * two tabs connecting, and this is the IO half `unmatchedRoot` deliberately does not do.
24
+ */
25
+ function daemonSitsInAProject(daemonRoot) {
26
+ try {
27
+ // Either mark counts. `.reticle.json` means somebody ran `init` here; an existing `.reticle/`
28
+ // means Reticle has been invited into this tree before, including by a version that predates
29
+ // the config file. A directory with neither has never agreed to hold anybody's session data.
30
+ return existsSync(daemonRoot) || hasProjectConfig(dirname(daemonRoot));
31
+ }
32
+ catch {
33
+ // Unreadable is not a licence to write. Treat it as somebody else's directory.
34
+ return false;
35
+ }
36
+ }
37
+ export function artifactRootResolver(daemonRoot) {
38
+ const daemonIsProject = daemonSitsInAProject(daemonRoot);
39
+ return (projectId, origin) => {
40
+ let registry = emptyProjectRegistry();
41
+ try {
42
+ const path = join(homedir(), ReticleDir.ROOT, PROJECT_REGISTRY_FILE);
43
+ registry = existsSync(path)
44
+ ? parseProjectRegistry(JSON.parse(readFileSync(path, 'utf8')))
45
+ : registry;
46
+ }
47
+ catch {
48
+ // A cache that cannot be read is an empty cache, never an error: the daemon still resolves
49
+ // through discovery, and falls back to its own root exactly as it did before this existed.
50
+ }
51
+ let discovery = { found: [], searched: [] };
52
+ try {
53
+ discovery = discoverProjectConfigs(process.cwd());
54
+ }
55
+ catch {
56
+ // Same reasoning: a diagnostic search that throws must not take a tool call with it.
57
+ }
58
+ const resolved = resolveArtifactRoot({
59
+ projectId,
60
+ candidates: projectCandidatesFrom(discovery, registry),
61
+ daemonRoot,
62
+ });
63
+ if (resolved.reason === ArtifactRootReason.MATCHED_PROJECT)
64
+ return resolved;
65
+ // Could not name the project. The old code wrote into the daemon's directory anyway and said
66
+ // nothing, which put `.reticle/` — journals included — into repositories nobody had
67
+ // instrumented. Evidence still has to live somewhere, so it goes to the user's own
68
+ // `~/.reticle/unmatched/<projectId>` instead, and the daemon says so rather than leaving
69
+ // somebody to find the files.
70
+ const root = unmatchedRoot({
71
+ daemonRoot,
72
+ daemonIsProject,
73
+ home: homedir(),
74
+ ...(projectId === undefined ? {} : { projectId }),
75
+ // Only reached when no project id survives the guard. Without it every app that never
76
+ // stamped one shares a single directory -- and that directory holds the durable half, so one
77
+ // app's learned expectations become another's.
78
+ ...(origin === undefined ? {} : { origin }),
79
+ });
80
+ if (root !== daemonRoot) {
81
+ log('artifact_root_unmatched', {
82
+ projectId: projectId ?? null,
83
+ reason: resolved.reason,
84
+ root,
85
+ });
86
+ }
87
+ return { ...resolved, root };
88
+ };
89
+ }
@@ -1,4 +1,4 @@
1
- import { type ProjectCandidate, type ProjectRegistry } from '@reticlehq/core';
1
+ import { type ProjectCandidate, type ProjectId, type ProjectRegistry } from '@reticlehq/core';
2
2
  import type { ConfigDiscovery } from '../../command/cli/config/config-discovery.js';
3
3
  /**
4
4
  * Which `.reticle/` a session's artifacts belong in.
@@ -38,7 +38,7 @@ export declare const ArtifactRootReason: {
38
38
  export type ArtifactRootReason = (typeof ArtifactRootReason)[keyof typeof ArtifactRootReason];
39
39
  interface ArtifactRootQuery {
40
40
  /** The connected session's HELLO projectId, when it sent one. */
41
- projectId: string | undefined;
41
+ projectId: ProjectId | undefined;
42
42
  /** Every project this machine knows about. Supplied, not gathered here — this stays pure. */
43
43
  candidates: readonly ProjectCandidate[];
44
44
  /** Where artifacts go when the project cannot be identified. Already a `.reticle` path. */
@@ -69,4 +69,41 @@ export declare function resolveArtifactRoot(query: ArtifactRootQuery): ArtifactR
69
69
  * the one confirmed a moment ago is the better survivor.
70
70
  */
71
71
  export declare function projectCandidatesFrom(discovery: ConfigDiscovery, registry: ProjectRegistry): ProjectCandidate[];
72
+ /** Where evidence goes when no project could be named and the daemon is a guest in this tree. */
73
+ export declare const UNMATCHED_SUBDIR = "unmatched";
74
+ /**
75
+ * The fallback root for a session whose project could not be resolved.
76
+ *
77
+ * Falling back to the daemon's own root was unconditional and silent, and that is how Reticle came
78
+ * to create `.reticle/` — session journals included, carrying URLs, request and response bodies and
79
+ * page text — in a user's BACKEND directory. Their editor starts the daemon there; the directory
80
+ * was never instrumented and never agreed to hold anybody's session data. They deleted it, and the
81
+ * next session wrote it again.
82
+ *
83
+ * Two cases, and only one of them was ever the intended behaviour:
84
+ *
85
+ * - 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.
89
+ *
90
+ * It is never dropped. A verdict with nowhere to live is a worse failure than one in an unexpected
91
+ * place, and the caller says out loud where it went.
92
+ *
93
+ * `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.
95
+ */
96
+ export declare function unmatchedRoot(query: {
97
+ daemonRoot: string;
98
+ /** Whether the daemon's own directory is a Reticle project — an IO question, answered by the caller. */
99
+ daemonIsProject: boolean;
100
+ /** The user's home directory. Passed in rather than read, so this stays pure. */
101
+ home: string;
102
+ projectId?: ProjectId | undefined;
103
+ /**
104
+ * Where the session was served from, when it is known. Used ONLY when no project id survives the
105
+ * segment guard — a real id always wins, because it is an identity and this is a stand-in.
106
+ */
107
+ origin?: string | undefined;
108
+ }): string;
72
109
  export {};
@@ -1,5 +1,5 @@
1
1
  import { join } from 'node:path';
2
- import { ReticleDir, projectCandidates, } from '@reticlehq/core';
2
+ import { SAFE_SEGMENT_PATTERN, ReticleDir, fnv1a, projectCandidates, } from '@reticlehq/core';
3
3
  /**
4
4
  * Which `.reticle/` a session's artifacts belong in.
5
5
  *
@@ -98,3 +98,60 @@ export function projectCandidatesFrom(discovery, registry) {
98
98
  : [{ projectId: config.projectId, directory: config.directory }]);
99
99
  return [...discovered, ...projectCandidates(registry)];
100
100
  }
101
+ /** Where evidence goes when no project could be named and the daemon is a guest in this tree. */
102
+ export const UNMATCHED_SUBDIR = 'unmatched';
103
+ /** What a project with NO identity at all is called on disk. Never blank, so the path is always real. */
104
+ const UNNAMED_PROJECT = 'unnamed';
105
+ /**
106
+ * The bucket for a project that could not name itself but was served from somewhere.
107
+ *
108
+ * `unnamed` used to take every one of these, which made it not a project's directory but the union
109
+ * of every project that ever failed to identify itself — sharing one `project.json`, one
110
+ * `envelopes.json`, one `flake.json` and one `assertion-tiers.json`. Those are the durable half:
111
+ * learned routes, per-route expectations, a quarantine ledger and an anti-downgrade floor. One
112
+ * app's floor silently becoming another app's floor is a wrong answer, not untidy disk.
113
+ *
114
+ * And it is the ORDINARY path, not an edge: a page that never stamped an id is an app instrumented
115
+ * without a build plugin, a page loaded before the plugin ran, or any tree where the daemon is a
116
+ * guest.
117
+ *
118
+ * The origin is the next-best identity available at that moment. It does not pretend to be a
119
+ * project id — two apps served on one port at different times still share a bucket — but that is a
120
+ * far smaller wrong than every unidentified app in the world sharing one.
121
+ */
122
+ const ORIGIN_BUCKET_PREFIX = 'origin-';
123
+ /**
124
+ * The fallback root for a session whose project could not be resolved.
125
+ *
126
+ * Falling back to the daemon's own root was unconditional and silent, and that is how Reticle came
127
+ * to create `.reticle/` — session journals included, carrying URLs, request and response bodies and
128
+ * page text — in a user's BACKEND directory. Their editor starts the daemon there; the directory
129
+ * was never instrumented and never agreed to hold anybody's session data. They deleted it, and the
130
+ * next session wrote it again.
131
+ *
132
+ * Two cases, and only one of them was ever the intended behaviour:
133
+ *
134
+ * - 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.
138
+ *
139
+ * It is never dropped. A verdict with nowhere to live is a worse failure than one in an unexpected
140
+ * place, and the caller says out loud where it went.
141
+ *
142
+ * `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.
144
+ */
145
+ export function unmatchedRoot(query) {
146
+ if (query.daemonIsProject)
147
+ return query.daemonRoot;
148
+ const id = query.projectId ?? '';
149
+ const named = SAFE_SEGMENT_PATTERN.test(id) && !id.includes('..');
150
+ return join(query.home, ReticleDir.ROOT, UNMATCHED_SUBDIR, named ? id : unnamedSegment(query.origin));
151
+ }
152
+ /** Hashed, not spelled: an origin carries a host and a port, and neither belongs in a path segment. */
153
+ function unnamedSegment(origin) {
154
+ if (origin === undefined || 0 === origin.trim().length)
155
+ return UNNAMED_PROJECT;
156
+ return `${ORIGIN_BUCKET_PREFIX}${fnv1a(origin.trim())}`;
157
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The values a drive types into this project's fields, kept so they are paid for once.
3
+ *
4
+ * A generated field value is not a model's opinion to be re-asked every run. It is a FIXTURE: the
5
+ * same label on the same app wants the same value tomorrow, a replay must send exactly what the
6
+ * recording sent or it is not a replay, and a human who dislikes "Acme Corp" should be able to open
7
+ * a file and write something else rather than argue with a model about it. So it lives beside the
8
+ * flows it belongs to, in git, in the project's own directory.
9
+ *
10
+ * Read once at the start of a drive and written once at the end: a drive that fills twenty fields
11
+ * must not write the file twenty times, and nothing else in the process is competing for it.
12
+ *
13
+ * Every failure is silent and answers "no values". A drive must never fail over a fixtures file,
14
+ * and the fallback -- the label heuristic -- is what every drive used before this existed.
15
+ */
16
+ import type { FileSystemPort } from '../fs/fs-port.js';
17
+ /** What a drive holds while it runs: a read-through map plus whatever it learned. */
18
+ export interface FillValueStore {
19
+ get(label: string): string | undefined;
20
+ set(label: string, value: string): void;
21
+ /** Persist, if anything was learned. Silent on failure, like every read here. */
22
+ flush(): Promise<void>;
23
+ }
24
+ /**
25
+ * A store that remembers within one drive and writes nothing.
26
+ *
27
+ * Used when there is no project directory to write to. A drive must never fail over its fixtures
28
+ * file, and that includes failing to FIND one: the values still de-duplicate inside the run, they
29
+ * are simply not carried into the next one.
30
+ */
31
+ export declare function memoryFillValues(): FillValueStore;
32
+ export declare function openFillValues(fs: FileSystemPort, root: string | undefined): Promise<FillValueStore>;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The values a drive types into this project's fields, kept so they are paid for once.
3
+ *
4
+ * A generated field value is not a model's opinion to be re-asked every run. It is a FIXTURE: the
5
+ * same label on the same app wants the same value tomorrow, a replay must send exactly what the
6
+ * recording sent or it is not a replay, and a human who dislikes "Acme Corp" should be able to open
7
+ * a file and write something else rather than argue with a model about it. So it lives beside the
8
+ * flows it belongs to, in git, in the project's own directory.
9
+ *
10
+ * Read once at the start of a drive and written once at the end: a drive that fills twenty fields
11
+ * must not write the file twenty times, and nothing else in the process is competing for it.
12
+ *
13
+ * Every failure is silent and answers "no values". A drive must never fail over a fixtures file,
14
+ * and the fallback -- the label heuristic -- is what every drive used before this existed.
15
+ */
16
+ import { ReticleDir } from '@reticlehq/core';
17
+ import { join } from 'node:path';
18
+ /** Stamped so a later format change can tell an old file from a broken one. */
19
+ const VERSION = 1;
20
+ /** A label is a UI string and can be anything; a value that is not a string is not usable. */
21
+ function parse(text) {
22
+ try {
23
+ const raw = JSON.parse(text);
24
+ if ('object' !== typeof raw || null === raw)
25
+ return {};
26
+ const values = raw.values;
27
+ if ('object' !== typeof values || null === values)
28
+ return {};
29
+ const out = {};
30
+ for (const [label, value] of Object.entries(values))
31
+ if ('string' === typeof value && 0 < value.length)
32
+ out[label] = value;
33
+ return out;
34
+ }
35
+ catch {
36
+ return {};
37
+ }
38
+ }
39
+ /**
40
+ * A store that remembers within one drive and writes nothing.
41
+ *
42
+ * Used when there is no project directory to write to. A drive must never fail over its fixtures
43
+ * file, and that includes failing to FIND one: the values still de-duplicate inside the run, they
44
+ * are simply not carried into the next one.
45
+ */
46
+ export function memoryFillValues() {
47
+ const values = new Map();
48
+ return {
49
+ get: (label) => values.get(label),
50
+ set: (label, value) => void values.set(label, value),
51
+ flush: () => Promise.resolve(),
52
+ };
53
+ }
54
+ export async function openFillValues(fs, root) {
55
+ if (root === undefined || 0 === root.length)
56
+ return memoryFillValues();
57
+ const path = join(root, ReticleDir.FILL_VALUES_FILE);
58
+ let values = {};
59
+ try {
60
+ if (await fs.exists(path))
61
+ values = parse(await fs.readFile(path));
62
+ }
63
+ catch {
64
+ /* an unreadable fixtures file is the same as an absent one */
65
+ }
66
+ let learned = false;
67
+ return {
68
+ get: (label) => values[label],
69
+ set: (label, value) => {
70
+ if (values[label] === value)
71
+ return;
72
+ values[label] = value;
73
+ learned = true;
74
+ },
75
+ async flush() {
76
+ if (!learned)
77
+ return;
78
+ try {
79
+ await fs.mkdir(root);
80
+ const file = { version: VERSION, values };
81
+ await fs.writeFile(path, `${JSON.stringify(file, null, 2)}\n`);
82
+ }
83
+ catch {
84
+ /* the drive already happened; losing the cache costs a regeneration, not a result */
85
+ }
86
+ },
87
+ };
88
+ }
@@ -1,4 +1,4 @@
1
- import { type FlowName, type SessionId } from '@reticlehq/core';
1
+ import { type ProjectId, type FlowName, type SessionId } from '@reticlehq/core';
2
2
  import { ContractReadError, type CapabilitiesContract, type RunId } from '@reticlehq/core';
3
3
  import type { FileSystemPort } from '../fs/fs-port.js';
4
4
  /** Resolved absolute paths inside a `.reticle/` root. Pure: join only, no IO, no cwd. */
@@ -17,6 +17,8 @@ export interface ReticleDirPaths {
17
17
  impact: string;
18
18
  /**.../.reticle/intent.json (what changes were supposed to make true — git-checked) */
19
19
  intent: string;
20
+ /**.../.reticle/fill-values.json (what a drive types into each labelled field — git-checked) */
21
+ fillValues: string;
20
22
  /**.../.reticle/visual (PNG baselines + diffs) */
21
23
  visual: string;
22
24
  /**.../.reticle/runs (verification-run artifacts) */
@@ -43,6 +45,14 @@ export declare function sessionDirPath(root: string, sessionId: SessionId): stri
43
45
  export declare function journalEventsPath(root: string, sessionId: SessionId): string;
44
46
  /** The append-only action ledger path for a session (.reticle/sessions/<id>/actions.jsonl). */
45
47
  export declare function journalActionsPath(root: string, sessionId: SessionId): string;
48
+ /**
49
+ * The event ledger's closure report for a session (.reticle/sessions/<id>/events.closed.json).
50
+ *
51
+ * A sidecar rather than a header inside `events.jsonl`, because the ledger is append-only and
52
+ * readers track a byte offset into it: a header would have to be rewritten in place, which shrinks
53
+ * or shifts the file and silently invalidates every cursor pointing at it.
54
+ */
55
+ export declare function journalClosedPath(root: string, sessionId: SessionId): string;
46
56
  /**
47
57
  * A sessionId must be a single safe path segment before it is joined into a disk path (rejects
48
58
  * '../', slashes, absolute, dotfiles). Session labels are user/tab-supplied, so this guard runs
@@ -82,9 +92,9 @@ export declare function visualDiffPath(root: string, name: string, runtime?: str
82
92
  * apps' flows of the same name; without one it's the flat legacy path (`.reticle/flows/<name>.json`),
83
93
  * which is also where pre-existing (untagged) flows stay. Callers pass ONLY a validated projectId.
84
94
  */
85
- export declare function flowPath(root: string, name: FlowName, projectId?: string): string;
95
+ export declare function flowPath(root: string, name: FlowName, projectId?: ProjectId): string;
86
96
  /** The directory a flow write targets: the per-project subdir, or the flat flows root for global. */
87
- export declare function flowDir(root: string, projectId?: string): string;
97
+ export declare function flowDir(root: string, projectId?: ProjectId): string;
88
98
  /**
89
99
  * The directory that must exist before a flow of this name can be written.
90
100
  *
@@ -96,7 +106,7 @@ export declare function flowDir(root: string, projectId?: string): string;
96
106
  * Derived from the resolved path rather than by splitting the name, so it cannot disagree with
97
107
  * `flowPath` about where the file actually goes.
98
108
  */
99
- export declare function flowParentDir(root: string, name: FlowName, projectId?: string): string;
109
+ export declare function flowParentDir(root: string, name: FlowName, projectId?: ProjectId): string;
100
110
  /**
101
111
  * A flow name may be NAMESPACED — `onboarding/signup` — and still never escapes `.reticle/`.
102
112
  *
@@ -10,6 +10,7 @@ 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),
13
14
  visual: join(root, ReticleDir.VISUAL_SUBDIR),
14
15
  runs: join(root, ReticleDir.RUNS_SUBDIR),
15
16
  sessions: join(root, ReticleDir.SESSIONS_SUBDIR),
@@ -36,6 +37,16 @@ export function journalEventsPath(root, sessionId) {
36
37
  export function journalActionsPath(root, sessionId) {
37
38
  return join(sessionDirPath(root, sessionId), ReticleDir.JOURNAL_ACTIONS_FILE);
38
39
  }
40
+ /**
41
+ * The event ledger's closure report for a session (.reticle/sessions/<id>/events.closed.json).
42
+ *
43
+ * A sidecar rather than a header inside `events.jsonl`, because the ledger is append-only and
44
+ * readers track a byte offset into it: a header would have to be rewritten in place, which shrinks
45
+ * or shifts the file and silently invalidates every cursor pointing at it.
46
+ */
47
+ export function journalClosedPath(root, sessionId) {
48
+ return join(sessionDirPath(root, sessionId), ReticleDir.JOURNAL_EVENTS_CLOSED_FILE);
49
+ }
39
50
  /**
40
51
  * A sessionId must be a single safe path segment before it is joined into a disk path (rejects
41
52
  * '../', slashes, absolute, dotfiles). Session labels are user/tab-supplied, so this guard runs
@@ -12,10 +12,31 @@ export interface FileSystemPort {
12
12
  * that lands on a `\n` boundary (the journal's line terminator, 1 ASCII byte) so the returned tail
13
13
  * starts on a valid UTF-8 char boundary. Optional — a FileSystemPort that omits it makes readers fall
14
14
  * back to a whole-file read.
15
+ *
16
+ * `maxBytes` caps what ONE call materialises as a single string. It is not a performance knob: V8
17
+ * cannot build a string longer than 0x1fffffe8 characters, and `Buffer.prototype.toString` THROWS
18
+ * past that rather than returning a shorter one — so a tail bigger than the ceiling is not a slow
19
+ * read, it is a failed one. When the cap bites, the NEWEST bytes are kept (a journal tail is read
20
+ * to answer about what just happened).
21
+ *
22
+ * Two guarantees make the returned text safe to account for in bytes, and an implementation owes
23
+ * both:
24
+ *
25
+ * - `from` is where the text ACTUALLY starts. It equals `byteOffset` when nothing was skipped,
26
+ * and a caller can compare the two to see that a cap bit. (Omitted by a port that does not
27
+ * implement the cap — read that as `byteOffset`, since such a port never skips.)
28
+ * - When the cap moves the start, `from` lands just past a `\n`, so the text begins at a record
29
+ * boundary and never with the replacement char of a character the window cut in half. A
30
+ * window holding no `\n` at all returns empty text with `from` at the end of what was read.
31
+ *
32
+ * `size` is the file's length as `fstat` reported it, which is NOT where the returned text ends:
33
+ * a truncation between the stat and the read, or any short read, returns less. Advance a cursor
34
+ * by what arrived, never by `size`.
15
35
  */
16
- readFileFrom?(path: string, byteOffset: number): Promise<{
36
+ readFileFrom?(path: string, byteOffset: number, maxBytes?: number): Promise<{
17
37
  text: string;
18
38
  size: number;
39
+ from?: number;
19
40
  }>;
20
41
  writeFile(path: string, data: string): Promise<void>;
21
42
  /** Append UTF-8 text, creating the file if absent — for the append-only JSONL journal. */
@@ -1,18 +1,46 @@
1
1
  import { access, appendFile, mkdir, open, readdir, readFile, realpath, rename, rm, stat, writeFile, } from 'node:fs/promises';
2
+ /** The JSONL record terminator, as a byte — the only boundary a capped tail read can align to. */
3
+ const NEWLINE_BYTE = 0x0a;
2
4
  /** Production adapter — the single import site of node:fs/promises in the server. */
3
5
  export function createNodeFileSystem() {
4
6
  return {
5
7
  readFile: (path) => readFile(path, 'utf8'),
6
- readFileFrom: async (path, byteOffset) => {
8
+ readFileFrom: async (path, byteOffset, maxBytes) => {
7
9
  const fh = await open(path, 'r');
8
10
  try {
9
11
  const { size } = await fh.stat();
10
12
  if (byteOffset >= size)
11
- return { text: '', size };
12
- const length = size - byteOffset;
13
+ return { text: '', size, from: byteOffset };
14
+ // Keep the newest bytes when the tail is over the caller's ceiling. Clamped into the file
15
+ // rather than trusted: a ceiling of zero would otherwise size a negative buffer.
16
+ const cut = maxBytes === undefined ? byteOffset : Math.max(byteOffset, size - Math.max(maxBytes, 0));
17
+ const capped = cut > byteOffset;
18
+ // One byte BEFORE that cut, so a cut landing exactly on a record boundary is recognised as
19
+ // one instead of sacrificing the record after it.
20
+ const start = capped ? cut - 1 : byteOffset;
21
+ const length = size - start;
13
22
  const buf = Buffer.allocUnsafe(length);
14
- await fh.read(buf, 0, length, byteOffset);
15
- return { text: buf.toString('utf8'), size };
23
+ const { bytesRead } = await fh.read(buf, 0, length, start);
24
+ // ONLY what the read returned. `allocUnsafe` hands back whatever was last in that heap
25
+ // block, and `fstat` runs one syscall ahead of `read` — so a file truncated in between, or
26
+ // any short read, would otherwise decode that memory and hand it back as journal text.
27
+ const got = buf.subarray(0, bytesRead);
28
+ if (!capped)
29
+ return { text: got.toString('utf8'), size, from: start };
30
+ // The ceiling moved the start, so the window may open inside a record whose head was never
31
+ // read. Drop up to the first boundary in BYTES, before decoding: `\n` is one byte and
32
+ // cannot occur inside a multi-byte sequence, so everything past it is intact UTF-8 and a
33
+ // whole number of records.
34
+ const newline = got.indexOf(NEWLINE_BYTE);
35
+ // No boundary in the whole window: there is no record here that can be read, and half of
36
+ // one is worse than none. `from` still says how far the read reached.
37
+ if (-1 === newline)
38
+ return { text: '', size, from: start + bytesRead };
39
+ return {
40
+ text: got.subarray(newline + 1).toString('utf8'),
41
+ size,
42
+ from: start + newline + 1,
43
+ };
16
44
  }
17
45
  finally {
18
46
  await fh.close();
@@ -1,6 +1,11 @@
1
1
  import { type ReticleEvent } from '@reticlehq/core';
2
2
  interface RouteLearningSession {
3
3
  url: string;
4
+ /**
5
+ * The `.reticle` this session's artifacts belong in, stamped on session-create. Undefined only
6
+ * for a caller that does not stamp one, which resolves to the daemon's own root.
7
+ */
8
+ artifactRoot?: string | undefined;
4
9
  onEvent(handler: (event: ReticleEvent) => void): () => void;
5
10
  }
6
11
  interface RouteLearningBridge {
@@ -19,6 +24,15 @@ export declare function routeFromEvent(event: ReticleEvent): string | undefined;
19
24
  /** Read the same route shape from a session's absolute URL. */
20
25
  export declare function routeFromUrl(value: string): string | undefined;
21
26
  export declare function routesFromEvents(events: readonly ReticleEvent[]): string[];
22
- /** Persist initial and subsequent routes for existing and future browser sessions. */
23
- export declare function attachRouteLearning(bridge: RouteLearningBridge, project: RouteLearningStore): void;
27
+ /**
28
+ * Persist initial and subsequent routes for existing and future browser sessions.
29
+ *
30
+ * `storeFor` resolves the store for ONE session's artifact root, rather than the daemon being handed
31
+ * a single store bound to its own `process.cwd()`. That binding was right only when the daemon
32
+ * happened to be started inside the app it was driving — and a user-scoped MCP registration, the
33
+ * common case, starts it wherever the editor's cwd is. The routes of every app then landed in that
34
+ * directory, which is how a `.reticle/` kept reappearing in a backend nobody had instrumented,
35
+ * while the driven project's own memory never filled.
36
+ */
37
+ export declare function attachRouteLearning(bridge: RouteLearningBridge, storeFor: (root: string | undefined) => RouteLearningStore): void;
24
38
  export {};
@@ -26,20 +26,35 @@ export function routesFromEvents(events) {
26
26
  return route === undefined ? [] : [route];
27
27
  });
28
28
  }
29
- /** Persist initial and subsequent routes for existing and future browser sessions. */
30
- export function attachRouteLearning(bridge, project) {
29
+ /**
30
+ * Persist initial and subsequent routes for existing and future browser sessions.
31
+ *
32
+ * `storeFor` resolves the store for ONE session's artifact root, rather than the daemon being handed
33
+ * a single store bound to its own `process.cwd()`. That binding was right only when the daemon
34
+ * happened to be started inside the app it was driving — and a user-scoped MCP registration, the
35
+ * common case, starts it wherever the editor's cwd is. The routes of every app then landed in that
36
+ * directory, which is how a `.reticle/` kept reappearing in a backend nobody had instrumented,
37
+ * while the driven project's own memory never filled.
38
+ */
39
+ export function attachRouteLearning(bridge, storeFor) {
31
40
  const attached = new WeakSet();
32
- const pending = new Set();
41
+ // Keyed by STORE rather than by root path: `storeFor` already answers one store per root, so this
42
+ // cannot disagree with it about which roots are distinct.
43
+ const pending = new Map();
33
44
  let flushTimer;
34
45
  const flush = () => {
35
46
  flushTimer = undefined;
36
- const routes = [...pending];
47
+ const batches = [...pending];
37
48
  pending.clear();
38
- if (routes.length > 0)
39
- void project.recordRoutes(routes).catch(() => undefined);
49
+ for (const [store, routes] of batches) {
50
+ if (routes.size > 0)
51
+ void store.recordRoutes([...routes]).catch(() => undefined);
52
+ }
40
53
  };
41
- const queue = (route) => {
42
- pending.add(route);
54
+ const queue = (store, route) => {
55
+ const routes = pending.get(store) ?? new Set();
56
+ routes.add(route);
57
+ pending.set(store, routes);
43
58
  if (flushTimer !== undefined)
44
59
  clearTimeout(flushTimer);
45
60
  flushTimer = setTimeout(flush, ROUTE_PERSIST_DEBOUNCE_MS);
@@ -48,13 +63,14 @@ export function attachRouteLearning(bridge, project) {
48
63
  if (attached.has(session))
49
64
  return;
50
65
  attached.add(session);
66
+ const store = storeFor(session.artifactRoot);
51
67
  const initial = routeFromUrl(session.url);
52
68
  if (initial !== undefined)
53
- queue(initial);
69
+ queue(store, initial);
54
70
  session.onEvent((event) => {
55
71
  const route = routeFromEvent(event);
56
72
  if (route !== undefined)
57
- queue(route);
73
+ queue(store, route);
58
74
  });
59
75
  };
60
76
  for (const session of bridge.sessions.all())
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The project memory store for ONE artifact root.
3
+ *
4
+ * `deps.project` is bound to the daemon's own root at construction, which is right only when the
5
+ * daemon happens to have been started inside the app it is driving — and a user-scoped MCP
6
+ * registration, the common case, starts it wherever the editor's cwd is. So every run record, every
7
+ * learned route and every observability high-water mark was written into that directory: a
8
+ * `.reticle/` appearing in a backend nobody instrumented, and a driven project whose own memory
9
+ * stayed empty. The cloud half of `recordReplayRun` had already been moved onto the session's root
10
+ * and its local half left behind, which is how a replay outcome could reach the dashboard and not
11
+ * the project it belonged to.
12
+ *
13
+ * Reads and writes both go through here, deliberately: a write that resolves to the app while a
14
+ * read still answers from the daemon's directory is worse than either alone — the record lands and
15
+ * is then invisible to the tool that exists to report it.
16
+ *
17
+ * Rebinding is cheap (a filesystem port, a root and a clock) and the single-project case allocates
18
+ * nothing: when the resolved root IS the daemon's, `deps.project` comes back unchanged.
19
+ */
20
+ import { ProjectStore } from './project-store.js';
21
+ import type { FileSystemPort } from './fs/fs-port.js';
22
+ import type { ToolDeps } from '../../surface/tools/tool-kit.js';
23
+ export declare function projectForRoot(deps: ToolDeps, root: string): ProjectStore;
24
+ /**
25
+ * The same question asked by the daemon rather than by a tool: one store per artifact root, so a
26
+ * session's learned routes land in the app that was driven and not in whatever directory the daemon
27
+ * was launched from. The daemon's own store is seeded in, so the single-project case allocates
28
+ * nothing and keeps the instance every other caller already holds.
29
+ */
30
+ export declare function projectStoreResolver(fs: FileSystemPort, daemonStore: ProjectStore, daemonRoot: string, now: () => number): (root: string | undefined) => ProjectStore;