@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
@@ -97,6 +97,18 @@ changeUndeclared) {
97
97
  // rule — part of what happened was not seen — so both belong in `blindSpots`, which is the only
98
98
  // input `decideVerified` reads for that.
99
99
  const gap = transportGapNote(windowEvents);
100
+ /**
101
+ * The DURABLE half of the same question. `queryEvents` above falls through to the journal once the
102
+ * ring buffer has evicted, and that ledger refuses writes at its byte ceiling — so a window read
103
+ * from it after the ceiling was reached is missing whatever the cap turned away. The in-band
104
+ * marker cannot carry this: it is stamped with a `t`, and any `since` later than that filters it
105
+ * straight back out, leaving a partial answer that looks exactly like a complete one.
106
+ *
107
+ * Called optionally because `Session` is stubbed by cast in a great many suites, and a method
108
+ * added to it therefore arrives as a runtime `undefined` in files that have nothing to do with
109
+ * this change — the cost recorded at the top of `fake-session.ts`, four times over.
110
+ */
111
+ const ledgerClosed = undefined !== (await session.journalWriteLoss?.());
100
112
  // A consumer rule that crashed belongs here too: the engine ran with fewer rules than it claims,
101
113
  // so part of what happened may simply not have been looked for.
102
114
  const impeachingNotes = [impeaching.note, gap, ...crashedRuleNotes()].filter((n) => n !== undefined);
@@ -104,6 +116,29 @@ changeUndeclared) {
104
116
  const outcomeUnread = unreadWriteLabels(windowEvents);
105
117
  const stillInFlight = inFlightRequestLabels(windowEvents);
106
118
  const effectiveInconclusive = inconclusive ?? (!pass ? session.preconditionFailure?.() : undefined);
119
+ /**
120
+ * Does a green here rest on the window having been COMPLETE?
121
+ *
122
+ * Not a refinement of the two losses below — it is what keeps either of them usable. Scarce loss
123
+ * is recorded for AGE eviction too, so `lostSince(0)` is true on any session past the 60s cutoff,
124
+ * and assert takes a caller-chosen `since` that is often 0. Impeaching every verdict over a wide
125
+ * window would make `unknown` the answer to everything, which is the failure this repo has already
126
+ * paid for once, when `unclean_capture` became the dominant cause of `unknown` in the field. A
127
+ * POSITIVE assertion that passed FOUND its evidence; things lost elsewhere do not unmake it.
128
+ */
129
+ const restsOnComplete = restsOnCompleteWindow(predicate);
130
+ /**
131
+ * The buffer evicted scarce evidence from this window.
132
+ *
133
+ * assert never consulted the buffer at all, on the reasoning that it "observes an already-open
134
+ * window" — but eviction happens on push regardless of who opened the window, so
135
+ * `{ console, absent: true }` returned `yes` over a window whose evidence was gone, which is
136
+ * absence of evidence read as evidence of absence. The act path needs no `restsOnComplete` guard:
137
+ * its cursor is the action's own.
138
+ */
139
+ const bufferLost = session.lostSince(since) && restsOnComplete;
140
+ /** The durable ledger refused writes, on a query path that read from it. Same rule, other store. */
141
+ const ledgerLost = ledgerClosed && restsOnComplete;
107
142
  const decision = decideVerified({
108
143
  pass,
109
144
  // What this claim needs to read, against what the page said it can see -- the protocol's
@@ -127,27 +162,19 @@ changeUndeclared) {
127
162
  honesty: buildHonestyBlock({
128
163
  grade: gradeOfPredicate(predicate),
129
164
  attribution: 'window',
130
- // Did the buffer evict SCARCE evidence belonging to this window, AND does a green here rest on
131
- // the window being complete. assert never consulted the buffer at all, on the reasoning that it
132
- // "observes an already-open window" — but eviction happens on push regardless of who opened the
133
- // window, so `{ console, absent: true }` returned `yes` over a window whose evidence was gone,
134
- // which is absence of evidence read as evidence of absence.
135
- //
136
- // The second half of the condition is not a refinement, it is what keeps this usable. Scarce
137
- // loss is recorded for AGE eviction too, so `lostSince(0)` is true on any session past the 60s
138
- // cutoff, and assert takes a caller-chosen `since` that is often 0. Impeaching every verdict
139
- // over a wide window would make `unknown` the answer to everything — the failure this repo has
140
- // already paid for once, when `unclean_capture` became the dominant cause of `unknown` in the
141
- // field. The act path needs no such guard: its cursor is the action's own.
142
- truncated: session.lostSince(since) && restsOnCompleteWindow(predicate),
165
+ truncated: bufferLost,
166
+ // The durable ledger stopped, and the SAME absence rule applies: a positive assertion that
167
+ // passed found its evidence, and events missing from the end of a file do not unmake it.
168
+ ledgerClosed: ledgerLost,
143
169
  coveragePartial: Coverage.PARTIAL === statement.coverage,
144
170
  ...(statement.note === undefined ? {} : { coverageNote: statement.note }),
145
171
  ...(0 === impeachingNotes.length ? {} : { blindSpots: impeachingNotes }),
146
- // Which loss, as an enum, beside the prose.
172
+ // Which loss, as an enum, beside the prose. Ours before the page's, and the memory one before
173
+ // the disk one: `verification.uncleanLoss` takes the first, and a window that lost both is
174
+ // more usefully reported as the eviction, which is the one a caller can still drive around.
147
175
  losses: [
148
- ...(session.lostSince(since) && restsOnCompleteWindow(predicate)
149
- ? [CaptureLoss.BUFFER_LOSS]
150
- : []),
176
+ ...(bufferLost ? [CaptureLoss.BUFFER_LOSS] : []),
177
+ ...(ledgerLost ? [CaptureLoss.JOURNAL_LOSS] : []),
151
178
  ...(gap === undefined ? [] : [CaptureLoss.TRANSPORT_GAP]),
152
179
  ...(impeaching.note === undefined ? [] : [CaptureLoss.BLIND_SPOT]),
153
180
  ],
@@ -1,4 +1,6 @@
1
1
  import { z } from 'zod';
2
+ import { sessionRoot } from '../../memory/project/session-root.js';
3
+ import { projectForRoot } from '../../memory/project/project-for-root.js';
2
4
  import { coverageRegressed, observabilityOf } from '@reticlehq/engine/evidence/observability.js';
3
5
  import { foldFeatureCapture } from './feature-capture.js';
4
6
  import { foldToolHitRate } from './tool-hit-rate.js';
@@ -153,10 +155,11 @@ export function buildCoverageTools(toolNames) {
153
155
  // The number, and the floor under it, together. A coverage figure that can only ever be
154
156
  // reported and never contradicted is one an agent learns to satisfy rather than to earn.
155
157
  const observability = observabilityOf(session.actedRefs(), gaps);
156
- const best = await deps.project.bestObservability();
158
+ const project = projectForRoot(deps, sessionRoot(deps, sessionId));
159
+ const best = await project.bestObservability();
157
160
  const regressed = coverageRegressed(best, observability);
158
161
  if (observability.percent !== undefined) {
159
- await deps.project.raiseObservability(observability.percent);
162
+ await project.raiseObservability(observability.percent);
160
163
  }
161
164
  return {
162
165
  total: parseControls(tree).length,
@@ -2,36 +2,12 @@ import { z } from 'zod';
2
2
  import { runTool } from './invoke-tool.js';
3
3
  import { buildErrorPayload } from './error-recovery.js';
4
4
  import { mergedNameRedirect, mergedNameMessage, retiredToolNames } from './merged-name-redirect.js';
5
+ import { CATALOGUE_SUMMARY_LENGTH, firstSentence } from './first-sentence.js';
5
6
  import { takeVersionSkewOnto } from '../../command/version/version-nudge.js';
6
7
  import { ReticleTool } from '@reticlehq/core';
7
8
  import { ADVERTISE_ALL_ENV } from './tool-surface.js';
8
9
  import { getSessionMetrics } from '../../telemetry/session-metrics.js';
9
10
  import { isPredicateParam, predicateGrammar, } from '@reticlehq/engine/question/predicate/predicate-eval.js';
10
- /**
11
- * On-demand tool loading for MCP — the answer to the per-turn tool-definition tax.
12
- *
13
- * A normal MCP server advertises every tool's full description + schema, and the client re-sends
14
- * all of them to the model on EVERY turn. Measured here: Reticle at ~5.6k–14.6k tokens/turn just for
15
- * definitions, which dominates a real agent loop and only grows as the tool surface grows.
16
- *
17
- * The `dynamic` profile advertises just TWO meta-tools (~hundreds of tokens, fixed regardless of
18
- * how many real tools exist):
19
- * reticle_tools — discover. No args ⇒ a compact catalog (name + one-line summary) of every tool.
20
- * names:[...] ⇒ full description + params for just those tools, loaded on demand.
21
- * reticle_run — invoke any tool by name. On a bad/unknown call it returns that tool's params as a
22
- * hint, so the model self-corrects without ever needing the schema up front.
23
- *
24
- * The model lists once, loads the 2–3 tools it actually needs, and calls them — paying for tool
25
- * detail only when used, not every turn. Works with any MCP client (no client-side support needed).
26
- */
27
- /** First sentence (purpose) of a description — keeps the catalog one line per tool. */
28
- function firstSentence(description) {
29
- const nl = description.indexOf('\n');
30
- const base = nl >= 0 ? description.slice(0, nl) : description;
31
- const dot = base.search(/\.\s/);
32
- const sentence = dot >= 0 ? base.slice(0, dot + 1) : base;
33
- return sentence.length > 140 ? `${sentence.slice(0, 139)}…` : sentence;
34
- }
35
11
  /** Compact param list from a tool's zod shape — name/required/description (the description already
36
12
  * carries the type and any enum hints, so no JSON-Schema machinery is needed). */
37
13
  function paramInfo(shape) {
@@ -73,6 +49,33 @@ export function buildDynamicTools(allTools, profile,
73
49
  callable) {
74
50
  const reachable = callable === undefined ? allTools : allTools.filter((tool) => callable.has(tool.name));
75
51
  const byName = new Map(reachable.map((t) => [t.name, t]));
52
+ const registered = new Map(allTools.map((tool) => [tool.name, tool]));
53
+ /**
54
+ * A name the catalogue cannot hand back as a callable tool.
55
+ *
56
+ * A redirect wins, because the capability moved and the old name is how an older instruction
57
+ * still asks for it. A name that is a real tool on a surface that cannot call it is the other
58
+ * case: `unknown tool` is what a typo gets, and a headline capability must not get the same
59
+ * sentence (#977). A name in neither set is a typo.
60
+ */
61
+ const explainAbsent = (name) => {
62
+ const moved = mergedNameRedirect(name);
63
+ if (moved !== undefined) {
64
+ return {
65
+ error: mergedNameMessage(name, moved),
66
+ tool: moved.tool,
67
+ ...(moved.action === undefined ? {} : { action: moved.action }),
68
+ };
69
+ }
70
+ if (callable !== undefined && registered.has(name)) {
71
+ return {
72
+ error: `${name} is a Reticle tool and this surface cannot call it. Restart the daemon with ` +
73
+ `${ADVERTISE_ALL_ENV}=1, then call reticle_run { tool: "${name}", args }.`,
74
+ tool: name,
75
+ };
76
+ }
77
+ return { error: 'unknown tool' };
78
+ };
76
79
  // The profile is a DAEMON-startup decision, so an agent that exported RETICLE_TOOL_PROFILE into its
77
80
  // own environment sees no change and has, until now, no way to tell. Reported with the catalog.
78
81
  const profileBlock = profile === undefined
@@ -135,7 +138,7 @@ callable) {
135
138
  // turns "these are the tools" into "these are the tools I was shown".
136
139
  const catalog = reachable.map((t) => ({
137
140
  name: t.name,
138
- summary: firstSentence(t.description),
141
+ summary: firstSentence(t.description, CATALOGUE_SUMMARY_LENGTH),
139
142
  }));
140
143
  // Names that USED to be tools, against the call that replaces each. Instructions written
141
144
  // against an earlier release are a permanent fact of the product — `reticle_crawl` is still
@@ -171,15 +174,7 @@ callable) {
171
174
  // `reticle_run` has answered a merged name with its replacement for a while; the tool
172
175
  // asked to DISCOVER tools still said "unknown tool", which is the wrong answer in the one
173
176
  // place an agent goes to resolve a name it is unsure of. Same redirect, same derivation.
174
- const moved = mergedNameRedirect(n);
175
- return moved === undefined
176
- ? { name: n, error: 'unknown tool' }
177
- : {
178
- name: n,
179
- error: mergedNameMessage(n, moved),
180
- tool: moved.tool,
181
- ...(moved.action === undefined ? {} : { action: moved.action }),
182
- };
177
+ return { name: n, ...explainAbsent(n) };
183
178
  }),
184
179
  ...(carriesPredicate ? { predicateGrammar: predicateGrammar() } : {}),
185
180
  });
@@ -190,9 +185,10 @@ callable) {
190
185
  args: z.record(z.unknown()).optional().describe('Arguments object for that tool.'),
191
186
  // Accepted AND FORWARDED, not merely tolerated. reticle_run is the only way to reach an
192
187
  // unadvertised tool, so on a machine running several projects it has to be aimable — and
193
- // `sessionId` is the shape an agent already uses on every other tool. Reported across 6 of 6
194
- // apps: it took this key, dropped it, resolved by the daemon's cwd project, and failed with
195
- // "no browser session for project X" while naming the very session it had been given.
188
+ // `sessionId` is the shape an agent already uses on every other tool. Reported from the field
189
+ // against every app it was tried on: it took this key, dropped it, resolved by the daemon's cwd
190
+ // project, and failed with "no browser session for project X" while naming the very session it
191
+ // had been given.
196
192
  sessionId: z
197
193
  .string()
198
194
  .optional()
@@ -232,14 +228,9 @@ callable) {
232
228
  getSessionMetrics().recordUnknownTool(name);
233
229
  // ...and for 22 of those names the capability DOES exist, it just moved when tools merged.
234
230
  // Answering "unknown tool" there is simply wrong; see merged-name-redirect.
235
- const moved = mergedNameRedirect(name);
236
- if (moved !== undefined) {
237
- return {
238
- error: mergedNameMessage(name, moved),
239
- tool: moved.tool,
240
- ...(moved.action === undefined ? {} : { action: moved.action }),
241
- };
242
- }
231
+ const absent = explainAbsent(name);
232
+ if ('unknown tool' !== absent.error)
233
+ return absent;
243
234
  return { error: `unknown tool '${name}'`, available: reachable.map((t) => t.name) };
244
235
  }
245
236
  // The outer aim, forwarded ONLY to a tool that declares a session — injecting it into one that
@@ -66,6 +66,12 @@ export declare const RECOVERY: {
66
66
  readonly COMMAND_TIMEOUT: string;
67
67
  readonly FLOW_STEP_MISSING: string;
68
68
  readonly NO_POOL: string;
69
+ /**
70
+ * The two ways a platform answer can refuse a drive. Neither is a fault, and both were telling the
71
+ * agent that a perfectly understood refusal might be a defect in Reticle — which is how a person
72
+ * who deliberately switched the harness off gets a bug report filed about their own decision.
73
+ */
74
+ readonly HARNESS_OFF: string;
69
75
  };
70
76
  /**
71
77
  * The actionable next move for a known error message, or undefined when none is recognized.
@@ -199,6 +199,15 @@ export const RECOVERY = {
199
199
  chromiumInstallCommand(bundledPlaywrightVersion()) +
200
200
  '` if that is what is missing. Meanwhile drive ' +
201
201
  'a tab the human already has open — reticle_sessions lists them.',
202
+ /**
203
+ * The two ways a platform answer can refuse a drive. Neither is a fault, and both were telling the
204
+ * agent that a perfectly understood refusal might be a defect in Reticle — which is how a person
205
+ * who deliberately switched the harness off gets a bug report filed about their own decision.
206
+ */
207
+ HARNESS_OFF: 'A human turned autonomous driving off for this project, or has not claimed the free harness ' +
208
+ 'months. This is a deliberate refusal and there is nothing to report: drive the app yourself ' +
209
+ 'through the MCP tools — reticle_navigate, then reticle_act_and_wait with an `until` — which is ' +
210
+ 'the same verification without a model driving it.',
202
211
  };
203
212
  /**
204
213
  * Which OWNER each recovery belongs to, for `tool_refused`.
@@ -236,6 +245,8 @@ const REASON_OF = {
236
245
  WRONG_TARGET: RefusalReason.UNSUPPORTED,
237
246
  BAD_ARGUMENTS: RefusalReason.BAD_ARGS,
238
247
  INVALID_NAME: RefusalReason.BAD_ARGS,
248
+ // Nothing about the app or the call is wrong; the feature is switched off or unpaid for.
249
+ HARNESS_OFF: RefusalReason.UNSUPPORTED,
239
250
  };
240
251
  /** Hint text back to its reason. The hints are distinct strings, so this inverts cleanly. */
241
252
  const REASON_BY_HINT = new Map(Object.entries(RECOVERY).map(([key, hint]) => [hint, REASON_OF[key]]));
@@ -248,6 +259,10 @@ const RULES = [
248
259
  { match: /no baseline named/i, hint: RECOVERY.MISSING_BASELINE },
249
260
  { match: /no (?:active|compiled) recording named/i, hint: RECOVERY.MISSING_RECORDING },
250
261
  { match: /pairing token is required/i, hint: RECOVERY.TOKEN_REQUIRED },
262
+ {
263
+ match: /Autonomous driving is turned OFF|no harness entitlement/i,
264
+ hint: RECOVERY.HARNESS_OFF,
265
+ },
251
266
  // Three conditions the daemon understands perfectly and still asked for a bug report about. Each
252
267
  // needs its own rule: none of them contains "no browser session connected" (the scope miss is
253
268
  // "no browser session FOR project 'x'", which is the opposite claim — sessions exist).
@@ -11,9 +11,11 @@
11
11
  * them deterministically with no model in the loop at all.
12
12
  */
13
13
  import { z } from 'zod';
14
- import { ReticleTool } from '@reticlehq/core';
14
+ import { ReticleTool, asRecord } from '@reticlehq/core';
15
15
  import { stepCountSchema } from './args/numeric-bounds.js';
16
- import { exploreApp, harnessAvailable, MSG_NO_HARNESS_KEY } from './harness-explore.js';
16
+ import { exploreApp, harnessAvailable, withLinkedCredential, MSG_NO_HARNESS_KEY, } from './harness-explore.js';
17
+ import { DRIVER_NAMES } from '../../features/harness/drivers.js';
18
+ import { describeDrive, replayedFlows } from '../../features/harness/drive-report.js';
17
19
  import { StopReason } from '../../features/harness/harness.js';
18
20
  export const EXPLORE_TOOLS = [
19
21
  {
@@ -27,6 +29,10 @@ export const EXPLORE_TOOLS = [
27
29
  maxSteps: stepCountSchema
28
30
  .optional()
29
31
  .describe('Ceiling on model turns. Bounds cost, not value — the drive is graded however it ends.'),
32
+ driver: z
33
+ .enum(DRIVER_NAMES)
34
+ .optional()
35
+ .describe('Which model drives. An unconfigured one is an error, never a substitution, so an A/B cannot measure the same driver twice. Omit for the default.'),
30
36
  sessionId: z
31
37
  .string()
32
38
  .optional()
@@ -35,9 +41,24 @@ export const EXPLORE_TOOLS = [
35
41
  // Everything the handler returns has to be declared, or a schema-aware client never sees it.
36
42
  outputSchema: {
37
43
  stopReason: z.string(),
44
+ /** Which driver actually drove, so a comparison can never misattribute its own result. */
45
+ driver: z.string(),
38
46
  steps: z.number(),
39
47
  savedFlows: z.array(z.string()),
40
- /** The model's own account of what it drove. NOT a verdict — the flows are the evidence. */
48
+ /** Flows that already existed and were driven and written again. A second run's ordinary result. */
49
+ rewroteFlows: z.array(z.string()),
50
+ /**
51
+ * What the drive set out to do, read from `.reticle` BEFORE it started — every recorded
52
+ * journey with the consequence that must still hold, and the declared intent nobody has
53
+ * tested. Returned so the caller can see the drive was aimed rather than wandering.
54
+ */
55
+ plan: z.object({ summary: z.string(), steps: z.array(z.unknown()) }),
56
+ /**
57
+ * What the drive did, derived from the calls it made and the verdicts the engine returned.
58
+ *
59
+ * Not the driver's narration. A driver that cannot write a sentence used to leave this empty,
60
+ * and a driver that can is the one witness with a reason to round "unknown" up to "worked".
61
+ */
41
62
  summary: z.string(),
42
63
  /** Present when the drive broke: a model that would not answer, a wedged browser. */
43
64
  error: z.string().optional(),
@@ -52,28 +73,80 @@ export const EXPLORE_TOOLS = [
52
73
  note: z.string().optional(),
53
74
  },
54
75
  handler: async (deps, args) => {
55
- if (!harnessAvailable(process.env))
76
+ // The credential `reticle link` already filed counts as configured, so somebody who has
77
+ // signed in and linked does not also have to export a key by hand.
78
+ const env = await withLinkedCredential(deps, process.env);
79
+ if (!harnessAvailable(env))
56
80
  throw new Error(MSG_NO_HARNESS_KEY);
57
81
  const persona = args['persona'];
58
82
  const maxSteps = args['maxSteps'];
59
83
  const sessionId = args['sessionId'];
60
- const { drive, savedFlows } = await exploreApp(deps, process.env, {
84
+ const driver = args['driver'];
85
+ const { drive, savedFlows, rewroteFlows, driverName, plan } = await exploreApp(deps, env, {
61
86
  ...('string' === typeof persona ? { focus: persona } : {}),
62
87
  ...('number' === typeof maxSteps ? { maxSteps } : {}),
63
88
  ...('string' === typeof sessionId ? { sessionId } : {}),
89
+ ...('string' === typeof driver ? { driverName: driver } : {}),
64
90
  });
65
91
  return {
66
92
  stopReason: drive.stopReason,
93
+ driver: driverName,
67
94
  steps: drive.steps,
68
95
  savedFlows: [...savedFlows],
69
- summary: drive.summary,
96
+ rewroteFlows: [...rewroteFlows],
97
+ plan: { summary: plan.summary, steps: [...plan.steps] },
98
+ // Derived, not narrated. The driver's own `summary` is appended only when it said
99
+ // something — it is the one part of this a model authored, so it goes last and is labelled.
100
+ summary: [
101
+ describeDrive(drive.toolCalls, [...savedFlows, ...rewroteFlows]),
102
+ ...(0 === drive.summary.length ? [] : [`The driver's own account: ${drive.summary}`]),
103
+ ].join('\n'),
70
104
  ...(drive.error === undefined ? {} : { error: drive.error }),
71
105
  usage: drive.usage,
72
- ...(0 === savedFlows.length ? { note: NOTHING_RECORDED[drive.stopReason] } : {}),
106
+ // The note only fires when the drive left NOTHING behind. A rewritten flow is a flow: it
107
+ // replays, it proves what it asserts, and telling its author that "nothing will replay" is
108
+ // a lie this tool used to tell on every second run.
109
+ /*
110
+ * The note fires only when the run left NOTHING behind — and a run that REPLAYED left
111
+ * plenty. Sixteen recorded journeys re-proved for zero model tokens is the cheap half of
112
+ * the plan doing its job, and telling its author to "raise maxSteps" reads as a failure.
113
+ */
114
+ ...(0 === savedFlows.length &&
115
+ 0 === rewroteFlows.length &&
116
+ 0 === replayedFlows(drive.toolCalls).length
117
+ ? { note: `${NOTHING_RECORDED[drive.stopReason]}${failureDetail(drive)}` }
118
+ : {}),
73
119
  };
74
120
  },
75
121
  },
76
122
  ];
123
+ /**
124
+ * The tool failure behind an empty drive, when there was one.
125
+ *
126
+ * "The drive ran out of steps before saving a flow" is true of a drive that explored happily and ran
127
+ * long, and ALSO of one that reached its save and was refused — and those need opposite responses.
128
+ * Without this the two are indistinguishable from outside the daemon, which cost a full debugging
129
+ * session: a driver whose `reticle_record{stop}` was rejected for a missing argument retried until
130
+ * the budget ended, and the only thing the caller ever saw was the advice to raise `maxSteps`.
131
+ */
132
+ function failureDetail(drive) {
133
+ const failed = drive.toolCalls.filter((call) => call.isError);
134
+ const last = failed[failed.length - 1];
135
+ if (last !== undefined) {
136
+ const message = asRecord(last.result)['error'];
137
+ return ` ${String(failed.length)} tool call(s) failed during the drive; the last was ${last.name}: ${'string' === typeof message ? message : 'no message'}`;
138
+ }
139
+ // Nothing threw, so the interesting case left is a save that was ACCEPTED and wrote nothing —
140
+ // an empty recording reports success, and from outside the daemon that is indistinguishable from
141
+ // a drive that never reached its save at all.
142
+ const saves = drive.toolCalls.filter((call) => ReticleTool.FLOW_SAVE === call.name);
143
+ const save = saves[saves.length - 1];
144
+ if (save !== undefined) {
145
+ const result = asRecord(save.result);
146
+ return ` The flow save was accepted but kept nothing: ${JSON.stringify({ stepCount: result['stepCount'], empty: result['empty'], warning: result['warning'] })}.`;
147
+ }
148
+ return ` The drive never reached a flow save (${String(drive.toolCalls.length)} tool calls made).`;
149
+ }
77
150
  /**
78
151
  * What an empty drive means, said rather than left for the caller to infer.
79
152
  *
@@ -55,6 +55,15 @@ function sessionFacts(deps, sessionId) {
55
55
  return { driven };
56
56
  }
57
57
  }
58
+ /** Said when a report arrives with no body at all. Names both accepted fields so the retry can work. */
59
+ const NO_BODY_REASON = 'the report has no body — send it as `text` (or `summary`, which is the same field). Nothing was filed.';
60
+ /** `text`, or the `summary` alias, whichever arrived. Empty when neither did — the handler refuses. */
61
+ function feedbackBody(args) {
62
+ const text = asString(args['text'])?.trim();
63
+ if (text !== undefined && '' !== text)
64
+ return text;
65
+ return asString(args['summary'])?.trim() ?? '';
66
+ }
58
67
  export const FEEDBACK_TOOLS = [
59
68
  {
60
69
  name: ReticleTool.FEEDBACK,
@@ -72,8 +81,22 @@ export const FEEDBACK_TOOLS = [
72
81
  .describe('bug = a Reticle tool returned a wrong or broken result; gap = Reticle could not observe something you needed at all; ambiguity = the verification ran but you could not tell pass from fail; feature_request = something that does not exist and would have helped; improvement = something that exists but is awkward or inefficient; experience = something worked notably well and is worth keeping (pair it with `rating`).'),
73
82
  text: z
74
83
  .string()
75
- .min(1)
84
+ .optional()
76
85
  .describe('For a failure: the root-cause analysis — the call you made, what you expected, what you got, and why Reticle is at fault. For a request: what you want, in your own words.'),
86
+ /**
87
+ * `text` under the name agents reach for unprompted.
88
+ *
89
+ * Incident: field reports of a call carrying a summary and a trace coming back
90
+ * "String must contain at least 1 character(s)" and being lost. The body had been put in
91
+ * `summary` — a field this tool never had. The MCP SDK validates input BEFORE the handler, so
92
+ * a required `text` made those calls unreachable and unanswerable: no handler ran, nothing
93
+ * could explain the rejection, and the report was dropped.
94
+ *
95
+ * So `text` is optional and this is its alias; the handler takes whichever arrived and refuses
96
+ * — with both names in the reason — only when neither did. Widening the schema is what buys
97
+ * the chance to say anything at all.
98
+ */
99
+ summary: z.string().optional().describe('Alias for `text`; send either.'),
77
100
  need: z
78
101
  .string()
79
102
  .optional()
@@ -124,10 +147,26 @@ export const FEEDBACK_TOOLS = [
124
147
  model: 'claude-opus-4',
125
148
  },
126
149
  handler: async (deps, args) => {
150
+ // Refuse here rather than in core's schema: core validates a REPORT, which has one body field
151
+ // and knows nothing about the `summary` alias, so its rejection reads "String must contain at
152
+ // least 1 character(s)" and names neither field. That sentence is what agents were given when
153
+ // their report was dropped, and it is unactionable — the agent cannot tell which of eight
154
+ // arguments was wrong.
155
+ const body = feedbackBody(args);
156
+ if ('' === body) {
157
+ return {
158
+ sent: false,
159
+ accepted: false,
160
+ reason: NO_BODY_REASON,
161
+ redacted: [],
162
+ context: undefined,
163
+ note: 'NOT accepted (see reason). Tell the human what you found so it is not lost.',
164
+ };
165
+ }
127
166
  const receipt = await submitFeedback({
128
167
  source: FeedbackSource.AGENT,
129
168
  kind: asString(args['kind']),
130
- text: asString(args['text']) ?? '',
169
+ text: body,
131
170
  ...optionalText(args, 'trace'),
132
171
  ...optionalText(args, 'need'),
133
172
  ...optionalText(args, 'impact'),
@@ -187,6 +226,14 @@ export const VERDICT_TOOLS = new Set([
187
226
  // inflate the count.
188
227
  ReticleTool.VERIFY,
189
228
  ReticleTool.RECONCILE,
229
+ // Replaying ONE saved flow proves a journey still works, which is a verification by any reading a
230
+ // user would give the word — and it was in neither the numerator nor the denominator. It matched
231
+ // no name tripwire (`/assert|verify/`), it declares no `verified` field for the shape guard to
232
+ // catch, and its own status vocabulary spells itself `ok | drift | error`, so the three
233
+ // independent things that could have noticed all said nothing. `verificationOf` maps that
234
+ // vocabulary; `error` stays UNKNOWN, because a flow that could not load is not a verdict about
235
+ // anybody's app.
236
+ ReticleTool.FLOW_REPLAY,
190
237
  ]);
191
238
  /**
192
239
  * Verifications between re-offers of the prompt, and how many times we will ever offer it.
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The opening sentence of a tool description, for the two surfaces that show a summary.
3
+ *
4
+ * There were two copies of this, and only one of them knew about abbreviations. The MCP surface's
5
+ * copy masks `e.g.` and friends before looking for a sentence end; the tool catalogue's private
6
+ * copy did not, so `reticle_session` reached every agent as
7
+ *
8
+ * Drive or inspect the SESSION itself, chosen by action: "tune" adjusts the presenter session (e.g.
9
+ *
10
+ * and stopped there. Everything after the abbreviation was cut, including `"yield" hands the tab
11
+ * back to the human and is MANDATORY before you stop driving`. The catalogue is the surface an
12
+ * agent reads to find out what exists, so the one instruction the product calls mandatory was
13
+ * missing from the only place it would have been discovered.
14
+ *
15
+ * This is the same defect the MCP copy already carried a long comment about, reappearing in a
16
+ * second copy written without the fix. So there is one implementation now, and the thing the two
17
+ * callers genuinely disagree about -- how much room a line gets -- is a parameter rather than a
18
+ * reason to fork the logic again.
19
+ */
20
+ /** What the MCP surface allows a one-line description. */
21
+ export declare const MCP_SUMMARY_LENGTH = 160;
22
+ /** What the catalogue allows, which is tighter: it prints one line per tool, for every tool. */
23
+ export declare const CATALOGUE_SUMMARY_LENGTH = 140;
24
+ export declare function firstSentence(description: string, maxLength?: number): string;