@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
@@ -8,7 +8,9 @@
8
8
  * deterministically with no model in the loop at all.
9
9
  */
10
10
  import type { ToolDeps } from './tool-kit.js';
11
- import { type HarnessResult, type ModelDriver } from '../../features/harness/harness.js';
11
+ import { type HarnessPlan } from './harness-plan.js';
12
+ import { type ConfigFetch } from '../../features/harness/platform-config.js';
13
+ import { type HarnessResult, type ModelDriver, type ToolOutcome } from '../../features/harness/harness.js';
12
14
  export interface ExploreOptions {
13
15
  /** Who to be, or what to accomplish. Appended to the standing instruction. */
14
16
  focus?: string;
@@ -16,13 +18,61 @@ export interface ExploreOptions {
16
18
  sessionId?: string;
17
19
  /** Hard ceiling on model turns. Bounds cost, not value — the drive is usable however it ends. */
18
20
  maxSteps?: number;
21
+ /**
22
+ * Which driver to use for THIS drive, overriding the environment.
23
+ *
24
+ * Per-call rather than only per-daemon because the question people actually have is comparative —
25
+ * "is the cheap driver good enough for my app?" — and answering it with an environment variable
26
+ * means restarting the daemon between arms, which is exactly the setup that let a stale daemon
27
+ * answer three runs of our own benchmark with one configuration.
28
+ */
29
+ driverName?: string;
19
30
  /** Injected for tests, and for anyone driving with a model this repo does not ship a binding for. */
20
31
  driver?: ModelDriver;
32
+ /**
33
+ * Skip asking the platform which driver this project prefers.
34
+ *
35
+ * Set by tests and by callers that have already decided. Not a user-facing switch: naming a
36
+ * driver explicitly already skips the lookup, which is the only reason anybody would want to.
37
+ */
38
+ skipPlatformConfig?: boolean;
39
+ /**
40
+ * The GET that asks the platform what this project wants. Injected ONLY by tests.
41
+ *
42
+ * It exists because the two things this answer decides — a driver preference and whether the drive
43
+ * may happen at all — were both unreachable without a network, and the refusal test that was
44
+ * supposed to cover the OFF switch was asserting on a path where the platform is never asked.
45
+ */
46
+ configFetch?: ConfigFetch;
21
47
  }
22
48
  export interface ExploreResult {
23
49
  drive: HarnessResult;
50
+ /** What the drive set out to do, read from `.reticle` before it started. */
51
+ plan: HarnessPlan;
24
52
  /** Flows that exist now and did not before — the part of a drive that is worth paying for twice. */
25
53
  savedFlows: readonly string[];
54
+ /**
55
+ * Flows that already existed and were written again by this drive.
56
+ *
57
+ * Split from `savedFlows` because the before/after name diff cannot see a rewrite, and read on its
58
+ * own it reported a drive that saved a perfectly good ten-step flow as having saved nothing — over
59
+ * which the caller was told "nothing is proved and nothing will replay". That sentence was false,
60
+ * and a false "nothing was verified" is the same class of mistake as a false "everything passed":
61
+ * the product exists to stop a drive lying about its own outcome, including to us.
62
+ *
63
+ * It happens on any SECOND drive, because a drive that covers the same app records the same
64
+ * journeys under the same names — so this is the ordinary case, not the corner one.
65
+ */
66
+ rewroteFlows: readonly string[];
67
+ /**
68
+ * Which driver actually drove.
69
+ *
70
+ * Reported rather than re-derived by the caller. A second copy of "which driver would be chosen"
71
+ * is a copy that can disagree with the one that chose, and the whole point of naming a driver is
72
+ * to attribute a result to it — a comparison that mislabels its own arms is worse than no
73
+ * comparison. `custom` is an injected driver, which is neither of ours to name.
74
+ */
75
+ driverName: string;
26
76
  }
27
77
  /**
28
78
  * Why the harness is unavailable, phrased as the way to make it available.
@@ -32,6 +82,29 @@ export interface ExploreResult {
32
82
  * print it, and two copies of this sentence would drift.
33
83
  */
34
84
  export declare const MSG_NO_HARNESS_KEY: string;
85
+ /** Asked for Jev specifically and it is not configured. Distinct from having no model at all. */
86
+ export declare const MSG_NO_JEV_KEY: string;
87
+ /** Asked for OpenAI specifically and it is not configured. Never substituted, same as Jev. */
88
+ export declare const MSG_NO_OPENAI_KEY: string;
89
+ /**
90
+ * The environment, plus the credential this machine already has.
91
+ *
92
+ * `reticle link` mints a project-scoped key and files it in `~/.reticle/credentials.json`; the CLI
93
+ * has read it from there for as long as it has existed. The harness did not, and read only
94
+ * `process.env` — so somebody who had signed in, linked their project and been told they were
95
+ * connected still got "no model configured to drive the app", and the only way out was to find the
96
+ * key in the console and export it by hand. Five manual steps to reach a feature they had already
97
+ * finished setting up.
98
+ *
99
+ * Resolved into the env rather than threaded through four call sites: the drivers and the
100
+ * preference lookup all read an env record, and giving them a completed one leaves each of them
101
+ * exactly as simple as it was. An EXPLICIT variable still wins — someone who exported a key meant
102
+ * that key, and CI has no linked project to read.
103
+ *
104
+ * The credential arrives through `deps.linkedCloud`, a port, because resolving it here would make
105
+ * the tool surface reach into `memory/cloud` — a reach the directory guard refused, correctly.
106
+ */
107
+ export declare function withLinkedCredential(deps: ToolDeps, env: Record<string, string | undefined>): Promise<Record<string, string | undefined>>;
35
108
  /** Is there a model the harness can drive with? A read, because "not configured" is not a failure. */
36
109
  export declare function harnessAvailable(env: Record<string, string | undefined>): boolean;
37
110
  /**
@@ -51,3 +124,54 @@ export declare function maxStepsFromEnv(env: Record<string, string | undefined>)
51
124
  * record and did not — and this product does not get to make that mistake about its own work.
52
125
  */
53
126
  export declare function exploreApp(deps: ToolDeps, env: Record<string, string | undefined>, options?: ExploreOptions): Promise<ExploreResult>;
127
+ /**
128
+ * What a drive actually left behind, split into flows that are NEW and flows it wrote over.
129
+ *
130
+ * Pure, and exported, because it is the part that was wrong and the part worth pinning. The
131
+ * integration around it — a real toolset dispatching a real save — is covered by driving a real app;
132
+ * what a test can usefully hold still is the reconciliation itself.
133
+ *
134
+ * A rewrite is only counted when the drive claims the save AND the store lists the name afterwards.
135
+ * The claim on its own is not evidence, which is the rule this function existed to keep: a model
136
+ * that says it saved something it did not must not be believed. Requiring both is what lets a
137
+ * rewrite be seen at all, since a name that was already there cannot show up in a before/after diff.
138
+ */
139
+ export declare function reconcileFlows(before: ReadonlySet<string>, after: readonly string[], toolCalls: readonly {
140
+ name: string;
141
+ args: Record<string, unknown>;
142
+ result: unknown;
143
+ isError: boolean;
144
+ }[]): {
145
+ savedFlows: readonly string[];
146
+ rewroteFlows: readonly string[];
147
+ };
148
+ /**
149
+ * What a person said about autonomous driving on the platform, honoured here.
150
+ *
151
+ * The switch existed, persisted and round-tripped, and the daemon read it and threw it away — so
152
+ * turning the harness OFF changed a value in a database and nothing else. A control that does not
153
+ * control anything is worse than no control: somebody turns it off, watches Reticle drive their app
154
+ * anyway, and now correctly distrusts every other switch in the product.
155
+ *
156
+ * Absent means ON. The platform's own default is on, and a machine that cannot reach the platform —
157
+ * offline, CI, no link — must not silently lose a feature it was never told to stop using.
158
+ */
159
+ export declare const MSG_HARNESS_DISABLED: string;
160
+ /**
161
+ * The other reason a drive can be refused, and it is NOT the same reason.
162
+ *
163
+ * A drive through the platform spends Reticle's model budget. That is free for three months and
164
+ * included on a paid plan, and outside both it is somebody else's money being spent on nothing. The
165
+ * message says which of the two it is and what to do, because "the harness is off" told to a person
166
+ * who never turned anything off is a support ticket rather than an answer.
167
+ */
168
+ export declare const MSG_HARNESS_UNCLAIMED: string;
169
+ /**
170
+ * A stored preference, kept only if this build can actually honour it.
171
+ *
172
+ * Exported because it is the whole of the asymmetry with the per-call argument, and the asymmetry
173
+ * is the part somebody will later think is a bug.
174
+ */
175
+ export declare function knownDriver(provider: string | undefined): string | undefined;
176
+ /** The recording started and never stopped, if there is one. Exported because it is the decision. */
177
+ export declare function openRecordingName(toolCalls: readonly ToolOutcome[]): string | undefined;
@@ -7,8 +7,19 @@
7
7
  * nothing grades) but the flows that now exist on disk, because every future run replays those
8
8
  * deterministically with no model in the loop at all.
9
9
  */
10
- import { ReticleEnv } from '@reticlehq/core';
11
- import { harnessDriver, harnessOptionsFromEnv } from '../../features/harness/driver.js';
10
+ import { ReticleEnv, ReticleTool, apiKeyFrom, asRecord } from '@reticlehq/core';
11
+ import { projectForRoot } from '../../memory/project/project-for-root.js';
12
+ import { harnessDriver, harnessOptionsFromEnv, } from '../../features/harness/driver.js';
13
+ import { fillValues } from '../../features/harness/fill-values.js';
14
+ import { openFillValues } from '../../memory/project/dir/fill-value-store.js';
15
+ import { ANTHROPIC_DRIVER_NAME, CUSTOM_DRIVER_NAME, DRIVER_NAMES, JEV_DRIVER_NAME, OPENAI_DRIVER_NAME, } from '../../features/harness/drivers.js';
16
+ import { jevDriver, jevOptionsFromEnv } from '../../features/harness/jev-driver.js';
17
+ import { buildDomainModel } from '../../judgement/domain/domain-model.js';
18
+ import { readContract } from '../../memory/project/dir/reticle-dir.js';
19
+ import { sessionRoot } from '../../memory/project/session-root.js';
20
+ import { buildHarnessPlan, planAsText } from './harness-plan.js';
21
+ import { openAiDriver, openAiOptionsFromEnv } from '../../features/harness/openai-driver.js';
22
+ import { fetchPlatformConfig } from '../../features/harness/platform-config.js';
12
23
  import { DEFAULT_MAX_STEPS, runHarness, } from '../../features/harness/harness.js';
13
24
  import { reticleToolset } from './harness-toolset.js';
14
25
  /**
@@ -18,11 +29,60 @@ import { reticleToolset } from './harness-toolset.js';
18
29
  * the one thing to do about it. Named here because both the CLI and the honest no-flows refusal
19
30
  * print it, and two copies of this sentence would drift.
20
31
  */
21
- export const MSG_NO_HARNESS_KEY = `No model configured to drive the app: set ${ReticleEnv.HARNESS_KEY} to let Reticle explore it ` +
22
- `for you. Without it, flows are recorded by your own coding agent through the MCP tools.`;
32
+ export const MSG_NO_HARNESS_KEY = `No model configured to drive the app: set ${ReticleEnv.HARNESS_KEY}, or run \`reticle link\` and ` +
33
+ `set ${ReticleEnv.CLOUD_KEY}, to let Reticle explore it for you. Without either, flows are ` +
34
+ `recorded by your own coding agent through the MCP tools.`;
35
+ /** Asked for Jev specifically and it is not configured. Distinct from having no model at all. */
36
+ export const MSG_NO_JEV_KEY = `The \`${JEV_DRIVER_NAME}\` driver was asked for but is not configured: set ` +
37
+ `${ReticleEnv.HARNESS_JEV_KEY}, or run \`reticle link\` and set ${ReticleEnv.CLOUD_KEY}. ` +
38
+ `Refusing rather than quietly driving with another model, which would misattribute the result.`;
39
+ /** Asked for OpenAI specifically and it is not configured. Never substituted, same as Jev. */
40
+ export const MSG_NO_OPENAI_KEY = `The \`${OPENAI_DRIVER_NAME}\` driver was asked for but is not configured: set ` +
41
+ `${ReticleEnv.HARNESS_OPENAI_KEY}, or run \`reticle link\` and set ${ReticleEnv.CLOUD_KEY}. ` +
42
+ `Refusing rather than quietly driving with another model, which would misattribute the result.`;
43
+ const msgUnknownDriver = (asked) => `Unknown harness driver \`${asked}\`. Known drivers: ${DRIVER_NAMES.join(', ')}.`;
44
+ /**
45
+ * The environment, plus the credential this machine already has.
46
+ *
47
+ * `reticle link` mints a project-scoped key and files it in `~/.reticle/credentials.json`; the CLI
48
+ * has read it from there for as long as it has existed. The harness did not, and read only
49
+ * `process.env` — so somebody who had signed in, linked their project and been told they were
50
+ * connected still got "no model configured to drive the app", and the only way out was to find the
51
+ * key in the console and export it by hand. Five manual steps to reach a feature they had already
52
+ * finished setting up.
53
+ *
54
+ * Resolved into the env rather than threaded through four call sites: the drivers and the
55
+ * preference lookup all read an env record, and giving them a completed one leaves each of them
56
+ * exactly as simple as it was. An EXPLICIT variable still wins — someone who exported a key meant
57
+ * that key, and CI has no linked project to read.
58
+ *
59
+ * The credential arrives through `deps.linkedCloud`, a port, because resolving it here would make
60
+ * the tool surface reach into `memory/cloud` — a reach the directory guard refused, correctly.
61
+ */
62
+ export async function withLinkedCredential(deps, env) {
63
+ if (apiKeyFrom(env) !== undefined)
64
+ return env;
65
+ try {
66
+ const linked = await deps.linkedCloud?.();
67
+ if (linked === undefined || null === linked)
68
+ return env;
69
+ return {
70
+ ...env,
71
+ [ReticleEnv.API_KEY]: linked.apiKey,
72
+ [ReticleEnv.CLOUD_URL]: env[ReticleEnv.CLOUD_URL] ?? linked.url,
73
+ };
74
+ }
75
+ catch {
76
+ // A credential store that cannot be read is "not linked", not an error. The harness is optional
77
+ // and its absence is a routine answer; a drive must never fail because a JSON file was odd.
78
+ return env;
79
+ }
80
+ }
23
81
  /** Is there a model the harness can drive with? A read, because "not configured" is not a failure. */
24
82
  export function harnessAvailable(env) {
25
- return harnessOptionsFromEnv(env) !== undefined;
83
+ return (harnessOptionsFromEnv(env) !== undefined ||
84
+ jevOptionsFromEnv(env) !== undefined ||
85
+ openAiOptionsFromEnv(env) !== undefined);
26
86
  }
27
87
  /**
28
88
  * Read the step ceiling out of the environment.
@@ -47,21 +107,347 @@ export function maxStepsFromEnv(env) {
47
107
  * record and did not — and this product does not get to make that mistake about its own work.
48
108
  */
49
109
  export async function exploreApp(deps, env, options = {}) {
50
- const driver = options.driver ?? buildDriver(env);
110
+ const refusal = await refusedByPlatform(env, options);
111
+ if (refusal !== undefined)
112
+ throw new Error(refusal);
113
+ const maxSteps = options.maxSteps ?? maxStepsFromEnv(env);
51
114
  const before = new Set(await deps.flows.list());
52
- const drive = await runHarness(driver, reticleToolset(deps, pinned(options)), {
53
- maxSteps: options.maxSteps ?? maxStepsFromEnv(env),
54
- ...(options.focus === undefined ? {} : { focus: options.focus }),
115
+ // `.reticle` FIRST, before the app and before anything reads a line of source. It already holds
116
+ // every saved flow, the consequence that must hold for each, and the declared intent nobody has
117
+ // tested — which is the whole of what a drive should be deciding against.
118
+ const plan = await readPlan(deps, options.sessionId);
119
+ /*
120
+ * The fixtures this project has already paid for.
121
+ *
122
+ * A drive is a sequence of DECISIONS and, occasionally, one COMPOSITION: what to type into a box
123
+ * whose label nothing can guess from. The cheap driver cannot write a string at all, so those
124
+ * boxes used to receive the words "reticle harness". This is the seam where a generating model
125
+ * answers instead -- for about 6% of steps, once per label, and never again on any later drive,
126
+ * because the answer is written into `.reticle` beside the flows that use it.
127
+ */
128
+ const fills = await openFillValues(deps.fs, safeRoot(deps, options.sessionId));
129
+ const requested = options.driverName ?? env[ReticleEnv.HARNESS_DRIVER] ?? (await preferredDriver(env, options));
130
+ const built = options.driver === undefined
131
+ ? buildDriver(env, maxSteps, plan, requested, fills)
132
+ : { driver: options.driver, name: CUSTOM_DRIVER_NAME };
133
+ const driver = built.driver;
134
+ const toolset = reticleToolset(deps, pinned(options));
135
+ const drive = await runHarness(driver, toolset, {
136
+ maxSteps,
137
+ // The plan rides in as standing instruction, so it is in front of the model on every turn
138
+ // rather than remembered from a first one. `focus` is the caller's own words and goes last:
139
+ // somebody who named a journey meant that journey, whatever the project's backlog says.
140
+ focus: [
141
+ planAsText(plan),
142
+ ...(options.focus === undefined ? [] : [`Focus: ${options.focus}`]),
143
+ ].join('\n\n'),
55
144
  });
145
+ // Written once, after the drive, whatever the drive did: a run that broke still learned what it
146
+ // learned, and the next one should not pay for it again.
147
+ await fills.flush();
148
+ // MANDATORY, and deliberately outside the loop. A drive that runs out of budget mid-journey, or
149
+ // breaks, or whose model simply stops asking for tools, leaves a recording open and everything it
150
+ // drove unsaved — work paid for and thrown away. Saving is not a decision any model gets to make
151
+ // and not something a step budget gets to cut off, so it happens here, after the loop, always.
152
+ await bankOpenRecording(toolset, drive);
56
153
  const after = await deps.flows.list();
57
- return { drive, savedFlows: after.filter((name) => !before.has(name)) };
154
+ return { drive, plan, driverName: built.name, ...reconcileFlows(before, after, drive.toolCalls) };
155
+ }
156
+ /**
157
+ * What a drive actually left behind, split into flows that are NEW and flows it wrote over.
158
+ *
159
+ * Pure, and exported, because it is the part that was wrong and the part worth pinning. The
160
+ * integration around it — a real toolset dispatching a real save — is covered by driving a real app;
161
+ * what a test can usefully hold still is the reconciliation itself.
162
+ *
163
+ * A rewrite is only counted when the drive claims the save AND the store lists the name afterwards.
164
+ * The claim on its own is not evidence, which is the rule this function existed to keep: a model
165
+ * that says it saved something it did not must not be believed. Requiring both is what lets a
166
+ * rewrite be seen at all, since a name that was already there cannot show up in a before/after diff.
167
+ */
168
+ export function reconcileFlows(before, after, toolCalls) {
169
+ const present = new Set(after);
170
+ const claimed = toolCalls
171
+ .filter((call) => ReticleTool.FLOW_SAVE === call.name && !call.isError)
172
+ .map((call) => {
173
+ // The flow's filename is `saveAs` when given, and the recording's name otherwise. Read from
174
+ // the result first, which is what the store actually wrote, and fall back to the request.
175
+ const result = asRecord(call.result)['name'];
176
+ if ('string' === typeof result)
177
+ return result;
178
+ const args = asRecord(call.args);
179
+ const saveAs = args['saveAs'];
180
+ const flowName = args['flowName'];
181
+ if ('string' === typeof saveAs)
182
+ return saveAs;
183
+ return 'string' === typeof flowName ? flowName : undefined;
184
+ })
185
+ .filter((name) => name !== undefined && present.has(name));
186
+ return {
187
+ savedFlows: after.filter((name) => !before.has(name)),
188
+ rewroteFlows: [...new Set(claimed.filter((name) => before.has(name)))],
189
+ };
190
+ }
191
+ /**
192
+ * The driver this project chose on the platform, if it chose one, we can reach it, and we have it.
193
+ *
194
+ * Only asked when nobody has been explicit, so the cost is paid exactly once per drive and never on
195
+ * a path where it could not change the answer. An unreachable platform returns undefined and the
196
+ * environment decides, exactly as it did before this lookup existed.
197
+ *
198
+ * A preference naming a driver this build does not have is IGNORED rather than refused, and that
199
+ * asymmetry with the per-call argument is deliberate. The platform offers providers this daemon may
200
+ * be too old to know about — it already offers `openai`, which has no binding here — so a stored
201
+ * preference is a statement about the account, not an instruction for this drive, and a daemon that
202
+ * refused to run because a web UI knew one more word than it does would be broken by its own
203
+ * upgrade cycle. Naming a driver in the CALL is an instruction, and an unknown one is still an
204
+ * error there.
205
+ *
206
+ * It is not a silent substitution either way: the result reports the driver that actually drove.
207
+ */
208
+ async function preferredDriver(env, options) {
209
+ if (true === options.skipPlatformConfig)
210
+ return undefined;
211
+ return knownDriver((await platformConfig(env, options))?.provider);
212
+ }
213
+ /**
214
+ * What a person said about autonomous driving on the platform, honoured here.
215
+ *
216
+ * The switch existed, persisted and round-tripped, and the daemon read it and threw it away — so
217
+ * turning the harness OFF changed a value in a database and nothing else. A control that does not
218
+ * control anything is worse than no control: somebody turns it off, watches Reticle drive their app
219
+ * anyway, and now correctly distrusts every other switch in the product.
220
+ *
221
+ * Absent means ON. The platform's own default is on, and a machine that cannot reach the platform —
222
+ * offline, CI, no link — must not silently lose a feature it was never told to stop using.
223
+ */
224
+ export const MSG_HARNESS_DISABLED = 'Autonomous driving is turned OFF for this project. Turn it back on in the Reticle dashboard ' +
225
+ '(Settings → Verification model), or drive the app yourself through the MCP tools.';
226
+ /**
227
+ * The other reason a drive can be refused, and it is NOT the same reason.
228
+ *
229
+ * A drive through the platform spends Reticle's model budget. That is free for three months and
230
+ * included on a paid plan, and outside both it is somebody else's money being spent on nothing. The
231
+ * message says which of the two it is and what to do, because "the harness is off" told to a person
232
+ * who never turned anything off is a support ticket rather than an answer.
233
+ */
234
+ export const MSG_HARNESS_UNCLAIMED = 'This workspace has no harness entitlement, so autonomous driving would run on Reticle’s model ' +
235
+ 'budget with nothing paying for it. Claim the free 3 months in the Reticle dashboard, or export a ' +
236
+ 'model API key of your own and drive with that.';
237
+ /**
238
+ * Whether a drive would be paid for by the person asking for it.
239
+ *
240
+ * Entitlement gates OUR spend, so it has no business stopping somebody who brought their own key:
241
+ * their harness costs us nothing whether they ever claim anything or not. Reading the same options
242
+ * the drivers read keeps the two answers from drifting apart.
243
+ */
244
+ function ownsAModelKey(env) {
245
+ // The DIRECT variables only. Every driver also accepts the platform key as a fallback, so asking
246
+ // `jevOptionsFromEnv` here would answer "they have a key" for exactly the person whose drive would
247
+ // be billed to us — which is the one case this gate exists for.
248
+ return [ReticleEnv.HARNESS_KEY, ReticleEnv.HARNESS_JEV_KEY, ReticleEnv.HARNESS_OPENAI_KEY].some((name) => {
249
+ const value = env[name];
250
+ return value !== undefined && 0 < value.length;
251
+ });
252
+ }
253
+ /** One read, however many questions are asked of the answer. */
254
+ function platformConfig(env, options) {
255
+ return options.configFetch === undefined
256
+ ? fetchPlatformConfig(env)
257
+ : fetchPlatformConfig(env, options.configFetch);
258
+ }
259
+ /**
260
+ * The reason this drive must not start, or `undefined` to go ahead.
261
+ *
262
+ * It FAILS OPEN, and that is worth stating plainly because the argument for it changed under it.
263
+ * `fetchPlatformConfig` answers `undefined` on a network error, a non-2xx, a body that does not
264
+ * parse, or a two-second timeout, and all four land here as "carry on". When this endpoint only
265
+ * answered "which model does this project prefer", failing open cost nothing: the drive used the
266
+ * environment and nobody was worse off. It now also answers "is anybody paying for this", and the
267
+ * same silence means a drive runs on Reticle's model budget unmetered.
268
+ *
269
+ * Kept open anyway, deliberately: a settings endpoint having a slow second must not break somebody
270
+ * mid-verification, and the exposure is one drive's worth of a very cheap model. But this is
271
+ * best-effort, not enforcement -- if the spend needs a hard floor, it belongs at the proxy, which
272
+ * is the only side that cannot be talked out of it by a dropped packet.
273
+ */
274
+ async function refusedByPlatform(env, options) {
275
+ if (true === options.skipPlatformConfig)
276
+ return undefined;
277
+ const config = await platformConfig(env, options);
278
+ if (config === undefined)
279
+ return undefined;
280
+ if (!config.harnessEnabled)
281
+ return MSG_HARNESS_DISABLED;
282
+ if (!config.harnessEntitled && !ownsAModelKey(env))
283
+ return MSG_HARNESS_UNCLAIMED;
284
+ return undefined;
285
+ }
286
+ /**
287
+ * A stored preference, kept only if this build can actually honour it.
288
+ *
289
+ * Exported because it is the whole of the asymmetry with the per-call argument, and the asymmetry
290
+ * is the part somebody will later think is a bug.
291
+ */
292
+ export function knownDriver(provider) {
293
+ if (provider === undefined)
294
+ return undefined;
295
+ return DRIVER_NAMES.some((name) => name === provider) ? provider : undefined;
296
+ }
297
+ /**
298
+ * Close and save whatever recording the drive left open.
299
+ *
300
+ * The driver reserves turns for its own teardown, which covers the ordinary ending. It does NOT
301
+ * cover a drive that broke, or one whose model went quiet, or one cut off by a budget the caller
302
+ * shortened — and in every one of those the app really was driven and the record of it is thrown
303
+ * away. Measured before this existed: a 24-step journey through a real dashboard, saved nothing.
304
+ *
305
+ * Failures are swallowed on purpose. This is a last chance, not a checkpoint: if the recording was
306
+ * already closed the stop is refused and there is nothing to do, and a drive must not fail at the
307
+ * finish line because the thing it was trying to rescue did not need rescuing.
308
+ */
309
+ async function bankOpenRecording(toolset, drive) {
310
+ const open = openRecordingName(drive.toolCalls);
311
+ if (open === undefined)
312
+ return;
313
+ try {
314
+ await toolset.invoke(ReticleTool.RECORD, { action: 'stop', recordingName: open });
315
+ await toolset.invoke(ReticleTool.FLOW_SAVE, {
316
+ flowName: open,
317
+ intent: `Autonomous drive of ${open}, banked after the run ended.`,
318
+ });
319
+ }
320
+ catch {
321
+ /* last chance, not a checkpoint */
322
+ }
323
+ }
324
+ /** The recording started and never stopped, if there is one. Exported because it is the decision. */
325
+ export function openRecordingName(toolCalls) {
326
+ let open;
327
+ for (const call of toolCalls) {
328
+ if (ReticleTool.RECORD !== call.name)
329
+ continue;
330
+ const args = asRecord(call.args);
331
+ const name = args['recordingName'];
332
+ if ('string' !== typeof name)
333
+ continue;
334
+ if ('start' === args['action'] && !call.isError)
335
+ open = name;
336
+ if ('stop' === args['action'] && name === open)
337
+ open = undefined;
338
+ }
339
+ return open;
340
+ }
341
+ /**
342
+ * Read the project's own record of what it does and what is proved about it.
343
+ *
344
+ * No browser, no model, no source. A failure here is "nothing is recorded yet", which is the
345
+ * ordinary state of a new project and not a reason to refuse to drive — so it answers an empty
346
+ * plan rather than throwing.
347
+ */
348
+ async function readPlan(deps, sessionId) {
349
+ try {
350
+ const flows = [];
351
+ for (const name of await deps.flows.list()) {
352
+ const loaded = await deps.flows.load(name);
353
+ if (loaded.ok)
354
+ flows.push(loaded.value);
355
+ }
356
+ const root = sessionRoot(deps, sessionId);
357
+ const contract = await readContract(deps.fs, root);
358
+ const project = await projectForRoot(deps, root).read();
359
+ return buildHarnessPlan(buildDomainModel(flows, contract.ok ? contract.capabilities : null, project.ok ? project.file.runs : []));
360
+ }
361
+ catch {
362
+ return {
363
+ steps: [],
364
+ summary: 'The project record could not be read; driving without a plan.',
365
+ vocabulary: [],
366
+ };
367
+ }
368
+ }
369
+ /**
370
+ * The project directory, or nothing.
371
+ *
372
+ * `sessionRoot` needs a session and a project to answer, and a drive can legitimately be asked for
373
+ * before either is resolvable. Everything else that reads `.reticle` here already treats that as
374
+ * "no project record" rather than as a failure; the fixtures file is no different, and a throw on
375
+ * this path would turn a missing directory into a refused drive.
376
+ */
377
+ function safeRoot(deps, sessionId) {
378
+ try {
379
+ return sessionRoot(deps, sessionId);
380
+ }
381
+ catch {
382
+ return undefined;
383
+ }
58
384
  }
59
385
  function pinned(options) {
60
386
  return options.sessionId === undefined ? {} : { sessionId: options.sessionId };
61
387
  }
62
- function buildDriver(env) {
63
- const configured = harnessOptionsFromEnv(env);
64
- if (configured === undefined)
65
- throw new Error(MSG_NO_HARNESS_KEY);
66
- return harnessDriver(configured);
388
+ /**
389
+ * Pick the driver.
390
+ *
391
+ * The Anthropic driver stays the default, and an explicit `RETICLE_HARNESS_DRIVER=jev` is the only
392
+ * thing that moves off it when both are configured — a driver change is a change in how the app gets
393
+ * driven, and inferring one from which key happens to be exported would swap it under people who
394
+ * merely linked their account.
395
+ *
396
+ * The fall-through is the other direction and is not a preference: with no Anthropic key and a
397
+ * platform key present, Jev is not the cheaper option, it is the only one. Choosing it there is what
398
+ * makes the harness work for somebody who never had a model API key of their own, which is most
399
+ * people — and the reason this driver is worth having at all.
400
+ */
401
+ function buildDriver(env, maxSteps, plan, requested, fills) {
402
+ // The two halves of what `.reticle` knows, handed to the one driver that can use both: what is
403
+ // worth doing, and the names the app uses for what it does. The second is what lets a declared
404
+ // consequence be SAVED rather than merely proved; see `consequencesFor`.
405
+ const fromReticle = {
406
+ plan: plan.steps,
407
+ vocabulary: plan.vocabulary,
408
+ /*
409
+ * Generation is offered ONLY when a generating model is already configured, and it stays out of
410
+ * the per-turn loop either way: put a text model on every turn and you pay its per-turn tax on
411
+ * the 94% of steps that are clicks, which is the entire cost the cheap driver exists to avoid.
412
+ * With no such key the label heuristic answers, exactly as it did before this existed.
413
+ */
414
+ fillValue: fillValues({
415
+ ...(fills === undefined ? {} : { cache: fills }),
416
+ ...(harnessOptionsFromEnv(env) === undefined
417
+ ? {}
418
+ : { generator: harnessOptionsFromEnv(env) }),
419
+ }),
420
+ };
421
+ const jev = jevOptionsFromEnv(env);
422
+ const anthropic = harnessOptionsFromEnv(env);
423
+ const asked = requested;
424
+ // A driver that was ASKED for and is not configured is an error, never a substitution. Quietly
425
+ // falling back would make every comparison between two drivers a possible lie about which one
426
+ // produced the result — and comparing them is the main reason anyone names one.
427
+ if (JEV_DRIVER_NAME === asked) {
428
+ if (jev === undefined)
429
+ throw new Error(MSG_NO_JEV_KEY);
430
+ // The Jev driver is told the budget because it has a teardown to reach; the Anthropic driver is
431
+ // not, because it calls `finish` itself and being handed a number it did not ask for is how a
432
+ // second copy of the budget starts drifting from the loop's.
433
+ return { driver: jevDriver({ ...jev, maxSteps, ...fromReticle }), name: JEV_DRIVER_NAME };
434
+ }
435
+ if (ANTHROPIC_DRIVER_NAME === asked) {
436
+ if (anthropic === undefined)
437
+ throw new Error(MSG_NO_HARNESS_KEY);
438
+ return { driver: harnessDriver(anthropic), name: ANTHROPIC_DRIVER_NAME };
439
+ }
440
+ if (OPENAI_DRIVER_NAME === asked) {
441
+ const openai = openAiOptionsFromEnv(env);
442
+ if (openai === undefined)
443
+ throw new Error(MSG_NO_OPENAI_KEY);
444
+ return { driver: openAiDriver(openai), name: OPENAI_DRIVER_NAME };
445
+ }
446
+ if (asked !== undefined && 0 < asked.length)
447
+ throw new Error(msgUnknownDriver(asked));
448
+ if (anthropic !== undefined)
449
+ return { driver: harnessDriver(anthropic), name: ANTHROPIC_DRIVER_NAME };
450
+ if (jev !== undefined)
451
+ return { driver: jevDriver({ ...jev, maxSteps, ...fromReticle }), name: JEV_DRIVER_NAME };
452
+ throw new Error(MSG_NO_HARNESS_KEY);
67
453
  }