@reticlehq/server 2.8.0 → 2.9.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 (292) hide show
  1. package/SKILL.md +130 -813
  2. package/dist/bridge/bridge.d.ts +23 -0
  3. package/dist/bridge/bridge.js +80 -2
  4. package/dist/bridge/bridge.test-harness.d.ts +5 -0
  5. package/dist/bridge/bridge.test-harness.js +6 -4
  6. package/dist/capsule/causal-summary.d.ts +16 -1
  7. package/dist/capsule/causal-summary.js +2 -1
  8. package/dist/cli/cli-doctor.js +41 -13
  9. package/dist/cli/cli-flow-commands.js +8 -8
  10. package/dist/cli/cli-launch.d.ts +0 -17
  11. package/dist/cli/cli-launch.js +0 -28
  12. package/dist/cli/cli-port.d.ts +24 -0
  13. package/dist/cli/cli-port.js +66 -35
  14. package/dist/cli/cli-verify.d.ts +21 -0
  15. package/dist/cli/cli-verify.js +45 -1
  16. package/dist/cli/config-discovery.d.ts +35 -0
  17. package/dist/cli/config-discovery.js +163 -0
  18. package/dist/cli/doctor-daemon-line.js +4 -1
  19. package/dist/cli/doctor-project-line.d.ts +37 -0
  20. package/dist/cli/doctor-project-line.js +35 -0
  21. package/dist/cli/doctor-rows.d.ts +51 -0
  22. package/dist/cli/doctor-rows.js +54 -0
  23. package/dist/cli/doctor-sessions-line.js +6 -3
  24. package/dist/cli/drive-attach.d.ts +99 -0
  25. package/dist/cli/drive-attach.js +203 -0
  26. package/dist/cli/drive-command.d.ts +13 -0
  27. package/dist/cli/drive-command.js +103 -0
  28. package/dist/cli/open-note.d.ts +20 -0
  29. package/dist/cli/open-note.js +37 -0
  30. package/dist/cli/status-next-action.d.ts +37 -0
  31. package/dist/cli/status-next-action.js +61 -0
  32. package/dist/cli.js +65 -70
  33. package/dist/events/contradictions.d.ts +32 -0
  34. package/dist/events/contradictions.js +9 -5
  35. package/dist/events/declared.d.ts +45 -0
  36. package/dist/events/declared.js +73 -0
  37. package/dist/events/echo-mismatch.d.ts +8 -2
  38. package/dist/events/echo-mismatch.js +33 -5
  39. package/dist/events/event-filters.d.ts +1 -1
  40. package/dist/events/event-filters.js +7 -4
  41. package/dist/events/predicate-eval.d.ts +17 -91
  42. package/dist/events/predicate-eval.js +85 -326
  43. package/dist/events/predicate-parse.js +31 -3
  44. package/dist/events/predicate-route.d.ts +32 -0
  45. package/dist/events/predicate-route.js +102 -0
  46. package/dist/events/predicate-schema.d.ts +99 -0
  47. package/dist/events/predicate-schema.js +309 -0
  48. package/dist/events/predicate.d.ts +8 -0
  49. package/dist/events/predicate.js +127 -5
  50. package/dist/flows/annotate-tools.js +3 -0
  51. package/dist/flows/annotate.js +18 -4
  52. package/dist/flows/coverage.d.ts +38 -5
  53. package/dist/flows/coverage.js +30 -4
  54. package/dist/flows/decision.d.ts +15 -0
  55. package/dist/flows/decision.js +1 -1
  56. package/dist/flows/flow-replay-run.js +8 -1
  57. package/dist/flows/flow-tools.js +9 -1
  58. package/dist/flows/replay.js +3 -0
  59. package/dist/honesty/accepted-write.d.ts +3 -0
  60. package/dist/honesty/accepted-write.js +8 -3
  61. package/dist/honesty/already-true.js +15 -0
  62. package/dist/honesty/blind-spots.js +4 -0
  63. package/dist/honesty/honesty.d.ts +11 -0
  64. package/dist/honesty/honesty.js +1 -0
  65. package/dist/honesty/uncaptured-bodies.d.ts +4 -2
  66. package/dist/honesty/uncaptured-bodies.js +9 -2
  67. package/dist/honesty/unread-outcome.d.ts +8 -0
  68. package/dist/honesty/unread-outcome.js +36 -9
  69. package/dist/honesty/unsettled.d.ts +9 -0
  70. package/dist/honesty/unsettled.js +7 -3
  71. package/dist/honesty/verified.d.ts +29 -3
  72. package/dist/honesty/verified.js +51 -8
  73. package/dist/http-server.d.ts +19 -0
  74. package/dist/http-server.js +202 -4
  75. package/dist/impact/impact-recorder.d.ts +23 -0
  76. package/dist/impact/impact-recorder.js +76 -0
  77. package/dist/impact/impact-store.d.ts +47 -0
  78. package/dist/impact/impact-store.js +171 -0
  79. package/dist/index.d.ts +7 -0
  80. package/dist/index.js +44 -10
  81. package/dist/init/agent-rules.d.ts +20 -0
  82. package/dist/init/agent-rules.js +36 -0
  83. package/dist/init/astro-patch.js +27 -1
  84. package/dist/init/confirm.d.ts +21 -3
  85. package/dist/init/confirm.js +32 -5
  86. package/dist/init/connect-steps.d.ts +2 -0
  87. package/dist/init/connect-steps.js +50 -0
  88. package/dist/init/csp-check.d.ts +38 -0
  89. package/dist/init/csp-check.js +92 -0
  90. package/dist/init/csp-doctor.d.ts +25 -0
  91. package/dist/init/csp-doctor.js +49 -0
  92. package/dist/init/cursor.js +6 -9
  93. package/dist/init/detect.d.ts +8 -0
  94. package/dist/init/detect.js +33 -3
  95. package/dist/init/mcp-clients.d.ts +1 -0
  96. package/dist/init/mcp-clients.js +51 -7
  97. package/dist/init/mcp.d.ts +15 -0
  98. package/dist/init/mcp.js +19 -0
  99. package/dist/init/plan-framework.d.ts +10 -0
  100. package/dist/init/plan-framework.js +25 -1
  101. package/dist/init/plan.d.ts +7 -2
  102. package/dist/init/plan.js +66 -30
  103. package/dist/init/run.js +11 -5
  104. package/dist/init/slash-command.d.ts +1 -1
  105. package/dist/init/slash-command.js +12 -4
  106. package/dist/init/snippets.d.ts +1 -1
  107. package/dist/init/snippets.js +35 -7
  108. package/dist/init/vite-config.js +24 -2
  109. package/dist/input/network-mock-tools.js +8 -1
  110. package/dist/input/scroll-find.d.ts +6 -0
  111. package/dist/input/scroll-find.js +17 -2
  112. package/dist/input/scroll-tools.js +4 -0
  113. package/dist/journal/attach-journal.d.ts +9 -1
  114. package/dist/journal/attach-journal.js +13 -2
  115. package/dist/journal/journal-query.d.ts +13 -0
  116. package/dist/journal/journal-query.js +28 -3
  117. package/dist/journal/workspace-gitignore.d.ts +10 -0
  118. package/dist/journal/workspace-gitignore.js +59 -0
  119. package/dist/mcp/mcp-proxy.d.ts +5 -66
  120. package/dist/mcp/mcp-proxy.js +180 -163
  121. package/dist/mcp/mcp.d.ts +10 -1
  122. package/dist/mcp/mcp.js +47 -27
  123. package/dist/mcp/proxy-handshake.d.ts +82 -1
  124. package/dist/mcp/proxy-handshake.js +109 -2
  125. package/dist/mcp/proxy-lifecycle.d.ts +10 -1
  126. package/dist/mcp/proxy-lifecycle.js +11 -2
  127. package/dist/mcp/proxy-log.d.ts +63 -0
  128. package/dist/mcp/proxy-log.js +132 -0
  129. package/dist/mcp/server-instructions.d.ts +17 -0
  130. package/dist/mcp/server-instructions.js +56 -0
  131. package/dist/mcp/shared-params.d.ts +27 -0
  132. package/dist/mcp/shared-params.js +36 -0
  133. package/dist/project/reticle-dir.d.ts +2 -0
  134. package/dist/project/reticle-dir.js +1 -0
  135. package/dist/session/connection-memory.d.ts +59 -0
  136. package/dist/session/connection-memory.js +108 -0
  137. package/dist/session/dev-command.d.ts +26 -0
  138. package/dist/session/dev-command.js +89 -0
  139. package/dist/session/dev-server-probe.d.ts +68 -0
  140. package/dist/session/dev-server-probe.js +102 -7
  141. package/dist/session/last-act.d.ts +26 -0
  142. package/dist/session/last-act.js +35 -1
  143. package/dist/session/live-control-tools.js +42 -5
  144. package/dist/session/no-session-diagnosis.d.ts +48 -2
  145. package/dist/session/no-session-diagnosis.js +176 -20
  146. package/dist/session/no-session-next-action.d.ts +46 -0
  147. package/dist/session/no-session-next-action.js +85 -0
  148. package/dist/session/no-session-watch.d.ts +40 -1
  149. package/dist/session/no-session-watch.js +165 -11
  150. package/dist/session/pending-commands.d.ts +12 -0
  151. package/dist/session/pending-commands.js +13 -1
  152. package/dist/session/session-manager.d.ts +4 -0
  153. package/dist/session/session-manager.js +35 -5
  154. package/dist/session/session.d.ts +10 -1
  155. package/dist/session/session.js +146 -5
  156. package/dist/telemetry/app-instrumented.d.ts +8 -0
  157. package/dist/telemetry/app-instrumented.js +10 -0
  158. package/dist/telemetry/bug-found.d.ts +18 -0
  159. package/dist/telemetry/bug-found.js +14 -0
  160. package/dist/telemetry/daemon-telemetry.js +24 -0
  161. package/dist/telemetry/install-source.d.ts +20 -0
  162. package/dist/telemetry/install-source.js +34 -0
  163. package/dist/telemetry/instrumentation-stall.d.ts +50 -0
  164. package/dist/telemetry/instrumentation-stall.js +90 -0
  165. package/dist/telemetry/session-metrics.d.ts +8 -0
  166. package/dist/telemetry/session-metrics.js +10 -0
  167. package/dist/telemetry/telemetry.d.ts +3 -1
  168. package/dist/telemetry/telemetry.js +13 -2
  169. package/dist/tools/act-tools.js +166 -29
  170. package/dist/tools/assert-verdict.js +32 -7
  171. package/dist/tools/browser-tools.js +10 -0
  172. package/dist/tools/dynamic-tools.js +16 -7
  173. package/dist/tools/error-recovery.js +9 -5
  174. package/dist/tools/invoke-tool.js +20 -3
  175. package/dist/tools/lease-hint.d.ts +30 -8
  176. package/dist/tools/lease-hint.js +65 -14
  177. package/dist/tools/lease-tools.d.ts +11 -0
  178. package/dist/tools/lease-tools.js +141 -96
  179. package/dist/tools/observe-tools.js +9 -3
  180. package/dist/tools/react-grace.d.ts +4 -0
  181. package/dist/tools/react-grace.js +6 -3
  182. package/dist/tools/read-tools.js +47 -1
  183. package/dist/tools/resolve-target.d.ts +33 -0
  184. package/dist/tools/resolve-target.js +53 -0
  185. package/dist/tools/settle-in-flight.d.ts +15 -0
  186. package/dist/tools/settle-in-flight.js +42 -2
  187. package/dist/tools/snapshot-delta.d.ts +3 -0
  188. package/dist/tools/snapshot-delta.js +67 -6
  189. package/dist/tools/tool-kit.d.ts +8 -0
  190. package/dist/tools/tool-surface.d.ts +60 -0
  191. package/dist/tools/tool-surface.js +85 -19
  192. package/dist/tools/tools.js +45 -4
  193. package/dist/tools/unadvertised-help.js +9 -6
  194. package/docs/README.md +1 -2
  195. package/docs/actions.mdx +41 -8
  196. package/docs/agent-cheatsheet.md +4 -2
  197. package/docs/architecture.md +6 -0
  198. package/docs/benchmarks.md +60 -12
  199. package/docs/best-practices.mdx +11 -6
  200. package/docs/capabilities.mdx +201 -54
  201. package/docs/cli/affected.mdx +6 -4
  202. package/docs/cli/capsules.mdx +9 -8
  203. package/docs/cli/cloud.mdx +30 -4
  204. package/docs/cli/doctor.mdx +41 -8
  205. package/docs/cli/drive.mdx +34 -15
  206. package/docs/cli/gate.mdx +6 -6
  207. package/docs/cli/help.mdx +8 -6
  208. package/docs/cli/hunt.mdx +18 -2
  209. package/docs/cli/identify.mdx +16 -2
  210. package/docs/cli/init.mdx +35 -8
  211. package/docs/cli/kill.mdx +5 -5
  212. package/docs/cli/license.mdx +1 -1
  213. package/docs/cli/open.mdx +6 -0
  214. package/docs/cli/restart.mdx +6 -6
  215. package/docs/cli/serve.mdx +10 -3
  216. package/docs/cli/status.mdx +29 -5
  217. package/docs/cli/stop.mdx +8 -0
  218. package/docs/cli/verify.mdx +20 -1
  219. package/docs/cli/version.mdx +1 -1
  220. package/docs/cli/watch.mdx +9 -2
  221. package/docs/cli.mdx +44 -22
  222. package/docs/debugging.md +3 -1
  223. package/docs/deploy-checks.md +30 -1
  224. package/docs/desktop.mdx +276 -80
  225. package/docs/docs.json +13 -12
  226. package/docs/enterprise.md +2 -0
  227. package/docs/faq.mdx +49 -35
  228. package/docs/fixtures.md +2 -0
  229. package/docs/flows.md +3 -1
  230. package/docs/for-agents.md +3 -3
  231. package/docs/frameworks.mdx +46 -7
  232. package/docs/gate-plan.md +4 -2
  233. package/docs/gates.md +7 -5
  234. package/docs/getting-started.md +53 -12
  235. package/docs/human-control.md +2 -0
  236. package/docs/index.mdx +43 -1
  237. package/docs/install-agentic.mdx +13 -4
  238. package/docs/install-manual.mdx +6 -4
  239. package/docs/instrumentation.mdx +7 -1
  240. package/docs/integration-patterns.md +3 -1
  241. package/docs/local-registry.md +4 -2
  242. package/docs/multi-agent-testing.md +3 -1
  243. package/docs/packages/babel-plugin.mdx +15 -6
  244. package/docs/packages/browser.mdx +2 -2
  245. package/docs/packages/core.mdx +26 -16
  246. package/docs/packages/electron.mdx +5 -7
  247. package/docs/packages/eslint-plugin.mdx +5 -3
  248. package/docs/packages/next.mdx +3 -5
  249. package/docs/packages/react.mdx +3 -3
  250. package/docs/packages/server.mdx +2 -2
  251. package/docs/packages/tauri.mdx +6 -6
  252. package/docs/packages/test.mdx +18 -7
  253. package/docs/packages/vite-plugin.mdx +4 -6
  254. package/docs/packages.mdx +14 -13
  255. package/docs/platform-integration.md +2 -0
  256. package/docs/predicates.mdx +76 -7
  257. package/docs/quickstart.mdx +51 -5
  258. package/docs/recipes.mdx +62 -22
  259. package/docs/skill-file.mdx +3 -1
  260. package/docs/state-management.mdx +2 -0
  261. package/docs/system-map.md +3 -1
  262. package/docs/telemetry-contract.md +16 -5
  263. package/docs/telemetry-events.mdx +63 -18
  264. package/docs/telemetry.md +8 -5
  265. package/docs/testing.md +35 -1
  266. package/docs/token-efficiency.md +8 -2
  267. package/docs/tools-act-and-wait.mdx +64 -14
  268. package/docs/tools-act-sequence.mdx +34 -17
  269. package/docs/tools-act.mdx +24 -11
  270. package/docs/tools-assert.mdx +69 -10
  271. package/docs/tools-console.mdx +11 -7
  272. package/docs/tools-inspect.mdx +11 -8
  273. package/docs/tools-navigate.mdx +41 -5
  274. package/docs/tools-network.mdx +42 -9
  275. package/docs/tools-observe.mdx +66 -10
  276. package/docs/tools-overview.mdx +26 -23
  277. package/docs/tools-query.mdx +52 -8
  278. package/docs/tools-session-and-feedback.mdx +30 -9
  279. package/docs/tools-sessions.mdx +39 -10
  280. package/docs/tools-snapshot.mdx +57 -3
  281. package/docs/tools-state.mdx +24 -1
  282. package/docs/tools-tools-and-run.mdx +40 -9
  283. package/docs/tools-wait-for.mdx +34 -15
  284. package/docs/troubleshooting.mdx +80 -10
  285. package/docs/usage.md +43 -21
  286. package/docs/vs-chrome-devtools-mcp.mdx +41 -0
  287. package/docs/vs-playwright-mcp.mdx +42 -2
  288. package/docs/vs-screenshots.mdx +42 -1
  289. package/docs/what-is-reticle.mdx +1 -1
  290. package/docs/why-reticle.mdx +3 -1
  291. package/package.json +5 -5
  292. package/docs/desktop-apps.md +0 -265
@@ -1,4 +1,5 @@
1
- import { EventType, MUTATING_METHODS, NetInitiator } from '@reticlehq/core';
1
+ import { EventType, MUTATING_METHODS, NetInitiator, isDevToolingUrl, } from '@reticlehq/core';
2
+ import { asString } from '../tools/tools-helpers.js';
2
3
  const IPC_METHOD = NetInitiator.IPC.toUpperCase();
3
4
  /**
4
5
  * A write whose outcome is sitting in a payload nobody read.
@@ -29,22 +30,48 @@ const IPC_METHOD = NetInitiator.IPC.toUpperCase();
29
30
  * The residual gap is real and narrower: a handler that RESOLVES with a failure inside its payload
30
31
  * is invisible without body capture. That case is `findBodyFailures`, which reads the payload and
31
32
  * catches it precisely — with capture on, where it belongs.
33
+ *
34
+ * - **the app's own traffic only.** The DEV TOOLCHAIN writes too: Next's overlay answers
35
+ * `POST /__nextjs_original-stack-frames` with a JSON body the moment the app logs one React
36
+ * warning, and nothing captures it. Judged as the app's write, that made every verdict in a Next
37
+ * dev app `unknown` — decided by a channel the caller never asked about, on a request the app did
38
+ * not make. `findContradictions` has split this traffic out for the same reason since the overlay
39
+ * turned correct navigations red; this rule was reading the unsplit window.
32
40
  */
33
41
  const OK_MIN = 200;
34
42
  const OK_MAX = 300;
35
- export function hasUnreadWriteOutcome(events) {
36
- return events.some((event) => {
43
+ /**
44
+ * The writes whose outcome went unread, as "METHOD url".
45
+ *
46
+ * Named rather than counted, because a caveat an agent cannot locate is a caveat it learns to skip.
47
+ * The sentence used to say "a write returned 2xx with a response body that was never recorded" and
48
+ * name no write, which is unactionable on any page that makes more than one call.
49
+ */
50
+ export function unreadWriteLabels(events) {
51
+ const labels = [];
52
+ for (const event of events) {
37
53
  if (event.type !== EventType.NET_REQUEST)
38
- return false;
54
+ continue;
39
55
  const method = ('string' === typeof event.data['method'] ? event.data['method'] : '').toUpperCase();
40
56
  if (method === IPC_METHOD || !MUTATING_METHODS.includes(method))
41
- return false;
57
+ continue;
58
+ const url = asString(event.data['url']);
59
+ if (isDevToolingUrl(url))
60
+ continue;
42
61
  const status = event.data['status'];
43
62
  if (typeof status !== 'number' || status < OK_MIN || status >= OK_MAX)
44
- return false;
63
+ continue;
45
64
  if (event.data['responseBody'] !== undefined)
46
- return false; // read — findBodyFailures judged it
65
+ continue; // read — findBodyFailures judged it
47
66
  const size = event.data['responseSize'];
48
- return 'number' === typeof size && size > 0;
49
- });
67
+ if ('number' !== typeof size || size <= 0)
68
+ continue;
69
+ const label = `${method} ${url ?? ''}`.trim();
70
+ if (!labels.includes(label))
71
+ labels.push(label);
72
+ }
73
+ return labels;
74
+ }
75
+ export function hasUnreadWriteOutcome(events) {
76
+ return unreadWriteLabels(events).length > 0;
50
77
  }
@@ -21,6 +21,15 @@ export interface UnsettledWindow {
21
21
  waitedFor: string;
22
22
  /** Requests that started in this window and had not come back, as "METHOD url". */
23
23
  stillInFlight: readonly string[];
24
+ /**
25
+ * Endpoints called more than once in this window, as "METHOD url ×N" — see `repeatedRequestLabels`.
26
+ *
27
+ * The case `stillInFlight` structurally cannot see: a retry loop against a dead backend completes
28
+ * every attempt, so nothing is ever outstanding, and the verdict fell through to a sentence that
29
+ * blamed unnamed churn on an app whose whole problem was one endpoint being hammered. Optional —
30
+ * a caller that does not supply it gets exactly the sentence that shipped before.
31
+ */
32
+ repeated?: readonly string[];
24
33
  }
25
34
  /**
26
35
  * The wait, in one phrase.
@@ -64,10 +64,14 @@ export function unsettledBecause(base, window) {
64
64
  if (window === undefined)
65
65
  return `${base} — re-check, or assert a consequence that settles`;
66
66
  if (0 === window.stillInFlight.length) {
67
+ const repeated = window.repeated ?? [];
67
68
  return (`${base}. It waited for ${window.waitedFor}, and no request started in this window was left ` +
68
- 'open — what kept the page busy was its own churn (a poll, a timer, an animation), so waiting ' +
69
- 'longer will not help. Assert the consequence you care about (a signal, a route change, or ' +
70
- 'store state) instead of waiting for idle, and re-check if the app is genuinely slow');
69
+ 'open — what kept the page busy was its own churn ' +
70
+ (0 === repeated.length
71
+ ? '(a poll, a timer, an animation)'
72
+ : `(these fired repeatedly: ${repeated.join(', ')} — a poll or a retrying query)`) +
73
+ ', so waiting longer will not help. Assert the consequence you care about (a signal, a route ' +
74
+ 'change, or store state) instead of waiting for idle, and re-check if the app is genuinely slow');
71
75
  }
72
76
  const shown = window.stillInFlight.slice(0, MAX_LISTED_REQUESTS);
73
77
  const rest = window.stillInFlight.length - shown.length;
@@ -42,22 +42,48 @@ interface VerifiedInputs {
42
42
  }[];
43
43
  /** Did a real frame flush before the wait gave up? */
44
44
  settled?: boolean;
45
+ /**
46
+ * The caller NAMED the expected consequence before acting — an `until`/`predicate` of its own,
47
+ * rather than the default "wait for the page to go idle".
48
+ *
49
+ * This is the epistemic core of the tool: a declaration made before the action cannot be
50
+ * rationalised after it. When such a declaration HOLDS, settlement is corroboration, not a veto —
51
+ * so the two clauses that answer "the app had not gone quiet" step aside for it. Measured in the
52
+ * field on three different apps: the expected text was on screen, the write returned 204, the
53
+ * nested verdict passed with evidence, and the verdict was `unknown` because the SPA polls, or
54
+ * because the tab was hidden and a hidden tab never fires the animation frame `settled` is read
55
+ * from. A hidden tab is the NORMAL state for agent-driven verification, so a signal that is always
56
+ * false there cannot be a precondition for a verdict.
57
+ *
58
+ * Narrow on purpose. It steps aside for settlement ONLY: an absence-derived finding about what the
59
+ * action DID — a duplicate write, a dead control, a route that rendered nothing, a response
60
+ * nothing consumed — still downgrades, and every positively OBSERVED contradiction still wins
61
+ * outright. Fixing a false negative must not open a path to a false positive.
62
+ */
63
+ declaredConsequence?: boolean;
45
64
  /**
46
65
  * A write in this window answered `202 Accepted` — the server took the request and has NOT
47
66
  * finished processing it.
48
67
  */
49
68
  outcomePending?: boolean;
50
69
  /**
51
- * A write in this window returned 2xx with a payload that was never recorded, so its outcome was
52
- * never read. See `hasUnreadWriteOutcome` — the status line describes the transport, not the result.
70
+ * Writes in this window that returned 2xx with a payload that was never recorded, as "METHOD url",
71
+ * so their outcome was never read. See `unreadWriteLabels` — the status line describes the
72
+ * transport, not the result.
73
+ *
74
+ * The LABELS rather than a flag: every other clause names the evidence that decided it, and this
75
+ * one named nothing, so an agent holding the caveat could not tell which call it was about.
76
+ * Empty means nothing went unread.
53
77
  */
54
- outcomeUnread?: boolean;
78
+ outcomeUnread?: readonly string[];
55
79
  /**
56
80
  * What the wait was for and what the window held when it ended — read ONLY by the two clauses that
57
81
  * answer UNSETTLED, which is the commonest reason a verdict comes back `unknown` and was also the
58
82
  * least actionable. Optional: without it those clauses say exactly what they said before.
59
83
  */
60
84
  unsettled?: UnsettledWindow;
85
+ /** A passing absence assertion targeted a region that the current capture could not observe. */
86
+ absenceBlindSpot?: string;
61
87
  }
62
88
  interface VerifiedVerdict {
63
89
  verified: Verified;
@@ -1,8 +1,10 @@
1
- import { Verified, VerifiedReason, isAbsenceDerived } from '@reticlehq/core';
1
+ import { ContradictionKind, MUTATING_METHODS, Verified, VerifiedReason, isAbsenceDerived, } from '@reticlehq/core';
2
2
  import { HonestyGrade } from './honesty.js';
3
3
  import { unsettledBecause } from './unsettled.js';
4
4
  export function decideVerified(inputs) {
5
5
  const { pass, honesty, contradictions = [], settled, outcomePending, outcomeUnread } = inputs;
6
+ /** The caller named a consequence before acting and it held — see `declaredConsequence`. */
7
+ const declaredHeld = true === inputs.declaredConsequence && true === pass;
6
8
  // Ahead of the failure clause, because a failure is only the most actionable fact when there WAS
7
9
  // one. An assertion nobody could evaluate is not a defect in the app, and calling it one was
8
10
  // putting the agent's own malformed calls into the bug count.
@@ -64,14 +66,42 @@ export function decideVerified(inputs) {
64
66
  // Absence-derived only: report it, do not assert failure. UNKNOWN is the honest answer — Reticle
65
67
  // drove the app and could not yet tell. The finding still rides out in `contradictions`, so an
66
68
  // agent that wants to wait and re-check has everything it needs.
67
- if (contradictions.length > 0) {
69
+ //
70
+ // ...except when the only finding is that a READ had not come back AND the caller declared the
71
+ // consequence that did. `request-never-settled` is a statement about when we stopped looking; the
72
+ // declaration is a statement about what was supposed to happen, made before it could. On a page
73
+ // that polls, the first is permanently true and vetoed every verdict the second earned.
74
+ //
75
+ // An open WRITE is the exception to the exception, and it is where the false-green defence lives:
76
+ // "the toast said Saved while the POST was still open" is the archetype this product exists to
77
+ // catch, and its outcome genuinely does not exist yet. A poll or a prefetch left hanging says
78
+ // nothing about the action; an unfinished mutation says the answer is not in yet. Read off the
79
+ // labels the window already carries — and when there are no labels to read, nothing is softened.
80
+ const openWrite = (inputs.unsettled?.stillInFlight ?? []).some((label) => MUTATING_METHODS.includes((label.split(' ')[0] ?? '').toUpperCase()));
81
+ const settlementOnly = declaredHeld &&
82
+ inputs.unsettled !== undefined &&
83
+ !openWrite &&
84
+ contradictions.every((c) => c.kind === ContradictionKind.REQUEST_NEVER_SETTLED);
85
+ if (contradictions.length > 0 && !settlementOnly) {
68
86
  const kinds = contradictions.map((c) => c.kind).join(', ');
69
87
  return {
70
88
  verified: Verified.UNKNOWN,
71
- verifiedReason: VerifiedReason.UNSETTLED,
89
+ // NOT `unsettled`: this clause fires whether or not the page went idle, and naming it after
90
+ // idle produced verdicts that read `unsettled` beside `settled: true`.
91
+ verifiedReason: VerifiedReason.EVIDENCE_INCOMPLETE,
72
92
  because: unsettledBecause(`the assertion held, but this window closed before the app finished (${kinds})`, inputs.unsettled),
73
93
  };
74
94
  }
95
+ // An absence assertion over a region Reticle cannot observe is not disproved, but it is not proved
96
+ // either. This is narrower than general partial coverage: the target itself is scoped to the blind
97
+ // region, so the DOM result cannot answer the question the caller asked.
98
+ if (inputs.absenceBlindSpot !== undefined) {
99
+ return {
100
+ verified: Verified.UNKNOWN,
101
+ verifiedReason: VerifiedReason.ABSENCE_BLIND_SPOT,
102
+ because: inputs.absenceBlindSpot,
103
+ };
104
+ }
75
105
  // Below the contradiction clause, and for the same reason a contradiction outranks a dirty
76
106
  // capture: evidence AGAINST beats absence of evidence, whichever absence it is. Both can hold at
77
107
  // once — assert `{ text: 'Saved' }` that was already on screen while the write 500s — and ordering
@@ -132,15 +162,22 @@ export function decideVerified(inputs) {
132
162
  //
133
163
  // UNKNOWN, not NO: nothing is known to have failed. The remedy is in the sentence, because an
134
164
  // agent that cannot act on a caveat will learn to skip it.
135
- if (true === outcomeUnread) {
165
+ if (outcomeUnread !== undefined && outcomeUnread.length > 0) {
136
166
  return {
137
167
  verified: Verified.UNKNOWN,
138
168
  verifiedReason: VerifiedReason.OUTCOME_UNREAD,
139
- because: 'a write returned 2xx with a response body that was never recorded, so its outcome is unread — a 200 describes the transport, not the result (a batch reports per-item failures in the body, and every GraphQL error is a 200). Enable it where your app calls connect(): `reticle.connect({ captureNetworkBodies: true })`, then re-run',
169
+ because: `a write returned 2xx with a response body that was never recorded (${outcomeUnread.join('; ')}), so its outcome is unread` +
170
+ ' — a 200 describes the transport, not the result (a batch reports per-item failures in the body, and every GraphQL error is a 200). Enable it where your app calls connect(): `reticle.connect({ captureNetworkBodies: true })`, then re-run',
140
171
  };
141
172
  }
142
173
  // Never settled: the page may still be moving, so the observation window may have closed early.
143
- if (false === settled) {
174
+ //
175
+ // Skipped when the caller DECLARED the consequence and it held. `settled` here is "a real
176
+ // animation frame fired within the budget", which a hidden or throttled tab never does — and a
177
+ // hidden tab is the ordinary case for agent-driven verification. It is corroboration; it is not
178
+ // the claim. It is still reported: `honesty.settled` carries the fact, and the sentence below says
179
+ // so in words.
180
+ if (false === settled && !declaredHeld) {
144
181
  return {
145
182
  verified: Verified.UNKNOWN,
146
183
  verifiedReason: VerifiedReason.UNSETTLED,
@@ -155,11 +192,17 @@ export function decideVerified(inputs) {
155
192
  // partial` — the prose contradicting the evidence block next to it. Whichever a reader believes,
156
193
  // one of them lied, and the whole point of this layer is that the sentence can be trusted on its
157
194
  // own. Seen on a one-way IPC send: coverage said the outcome was unobservable, `because` said clean.
195
+ // A green that rests on the declaration rather than on idle says so. The page not going quiet is a
196
+ // real fact about the app — a poll, a timer, a throttled tab — and a caller who cares must be able
197
+ // to see it without re-deriving it, so it is stated here and carried in `honesty.settled`.
198
+ const notIdle = false === settled
199
+ ? ', though the page never went idle — this rests on the consequence you declared, not on the page going quiet'
200
+ : '';
158
201
  return {
159
202
  verified: Verified.YES,
160
203
  verifiedReason: VerifiedReason.PROVED,
161
204
  because: true === honesty.coverage?.partial
162
- ? `assertion held at ${honesty.grade} grade with no channel disagreeing, but coverage was PARTIAL — see \`coverage\` for what went unobserved`
163
- : `assertion held at ${honesty.grade} grade over a clean capture with no channel disagreeing`,
205
+ ? `assertion held at ${honesty.grade} grade with no channel disagreeing, but coverage was PARTIAL — see \`coverage\` for what went unobserved${notIdle}`
206
+ : `assertion held at ${honesty.grade} grade over a clean capture with no channel disagreeing${notIdle}`,
164
207
  };
165
208
  }
@@ -11,6 +11,15 @@ export interface SharedServer {
11
11
  attachMcp(factory: () => McpServer): void;
12
12
  /** Register the JSON the daemon returns from GET /status (live sessions + health for `reticle status`). */
13
13
  attachStatus(provider: () => unknown): void;
14
+ /**
15
+ * Register the handler behind POST /drive: open a driveable browser at `url` and answer with the
16
+ * session it registered.
17
+ *
18
+ * This is how `reticle drive` stops fighting the daemon for the bridge port. The daemon already
19
+ * owns the browser plane, so driving is a request to it — see cli/drive-attach.ts. Left
20
+ * unattached, the route 404s, which is exactly what a newer CLI needs to hear from an older daemon.
21
+ */
22
+ attachDrive(provider: (url: string) => Promise<unknown>): void;
14
23
  /**
15
24
  * Register a callback fired when the AGENT presence changes — true when the first MCP client (any
16
25
  * agent: Codex/OpenCode/Claude/Hermes) connects, false when the last one disconnects. Agent-
@@ -18,6 +27,15 @@ export interface SharedServer {
18
27
  * "is an agent attached?". The daemon uses this to tell the panel "agent live" vs "agent stopped".
19
28
  */
20
29
  attachAgentPresence(cb: (connected: boolean) => void): void;
30
+ /**
31
+ * Tell every attached MCP proxy that this daemon is retiring, BEFORE the sockets go.
32
+ *
33
+ * The proxy sees a clean stream end whether the daemon shut down on schedule or died under it, so
34
+ * without this the two are one row and the transport-stability metric is dominated by the designed
35
+ * case. Separate from `close()` because it must run before the shutdown work rather than during it:
36
+ * `close()` is also the cleanup path of a start that failed, where there is nobody to tell.
37
+ */
38
+ announceShutdown(): void;
21
39
  close(): Promise<void>;
22
40
  }
23
41
  /**
@@ -32,3 +50,4 @@ export interface SharedServer {
32
50
  export declare function createSharedServer(options?: {
33
51
  token?: string;
34
52
  }): SharedServer;
53
+ export declare function driveSchemeRefusal(url: string): string | undefined;
@@ -1,6 +1,6 @@
1
1
  import * as http from 'node:http';
2
2
  import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
3
- import { MCP_SSE_PATH, MCP_MESSAGE_PATH, STATUS_PATH } from '@reticlehq/core';
3
+ import { MCP_SSE_PATH, MCP_MESSAGE_PATH, MCP_SHUTDOWN_EVENT, STATUS_PATH, DRIVE_PATH, } from '@reticlehq/core';
4
4
  import { getSessionMetrics } from './telemetry/session-metrics.js';
5
5
  import { noteAgentPeer, PEER_VERSION_PARAM, PEER_CONTRACT_PARAM } from './version/peer-announce.js';
6
6
  import { log } from './log.js';
@@ -18,8 +18,15 @@ import { isLoopbackHost, isLocalWebOrigin, isLoopbackPeer, requestToken, tokensM
18
18
  export function createSharedServer(options = {}) {
19
19
  let mcpFactory;
20
20
  let statusProvider;
21
+ let driveProvider;
21
22
  let agentPresence;
22
23
  const transports = new Map();
24
+ /**
25
+ * The raw response behind each SSE session, so the shutdown announcement can be written as an SSE
26
+ * frame the proxy reads and the agent never sees. `SSEServerTransport.send` only speaks JSON-RPC,
27
+ * and this message is addressed to the proxy rather than to the client behind it.
28
+ */
29
+ const sseStreams = new Map();
23
30
  const token = options.token;
24
31
  // The agent control plane (MCP transport) and /status carry the same trust as the browser WS: a
25
32
  // loopback peer is trusted (the local stdio proxy and `reticle status` always dial 127.0.0.1), but any
@@ -45,9 +52,23 @@ export function createSharedServer(options = {}) {
45
52
  };
46
53
  const httpServer = http.createServer((req, res) => {
47
54
  const rawUrl = req.url ?? '/';
48
- const url = new URL(rawUrl, 'http://localhost');
55
+ // Node's HTTP parser accepts request targets the WHATWG URL parser refuses (`//` among them), and
56
+ // a throw in a `request` listener is an uncaughtException — which the daemon answers by exiting,
57
+ // taking every agent and every browser session on this port with it, from whichever project. One
58
+ // malformed line on a loopback socket, and it lands BEFORE the authorization check below, so
59
+ // nothing had to be trusted to send it.
60
+ const url = parseRequestTarget(rawUrl);
61
+ if (null === url) {
62
+ log('http_bad_request_target', {});
63
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
64
+ res.end('bad request target');
65
+ return;
66
+ }
49
67
  const path = url.pathname;
50
- if (path === STATUS_PATH || path === MCP_SSE_PATH || path === MCP_MESSAGE_PATH) {
68
+ if (path === STATUS_PATH ||
69
+ path === DRIVE_PATH ||
70
+ path === MCP_SSE_PATH ||
71
+ path === MCP_MESSAGE_PATH) {
51
72
  if (!authorized(req, url)) {
52
73
  res.writeHead(401, { 'Content-Type': 'text/plain' });
53
74
  res.end('unauthorized');
@@ -60,6 +81,58 @@ export function createSharedServer(options = {}) {
60
81
  res.end(body);
61
82
  return;
62
83
  }
84
+ if ('POST' === req.method && path === DRIVE_PATH) {
85
+ if (driveProvider === undefined) {
86
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
87
+ res.end('not found');
88
+ return;
89
+ }
90
+ // A state-changing route must require a content type that a browser cannot send without a
91
+ // preflight. `text/plain`, `application/x-www-form-urlencoded` and `multipart/form-data` make
92
+ // a POST a CORS SIMPLE request: the browser sends it cross-origin with no preflight, and
93
+ // blocking the response afterwards does not un-drive the browser. Since loopback origins are
94
+ // authorized here by design, any page on any localhost port could otherwise reach this route
95
+ // by guessing ours, and drive a browser to a URL it chose.
96
+ //
97
+ // Requiring `application/json` forces a preflight, which the browser will not complete without
98
+ // our opt-in, and we do not opt in. One header check closes the whole class.
99
+ if (!isJsonRequest(req)) {
100
+ res.writeHead(415, { 'Content-Type': 'text/plain' });
101
+ res.end('expected content-type: application/json');
102
+ return;
103
+ }
104
+ const provider = driveProvider;
105
+ readBody(req)
106
+ .then(async (body) => {
107
+ const url = urlFromDriveRequest(body);
108
+ if (url === undefined) {
109
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
110
+ res.end('drive requires a url');
111
+ return;
112
+ }
113
+ // Before the browser is asked for anything. See driveSchemeRefusal.
114
+ const refusal = driveSchemeRefusal(url);
115
+ if (refusal !== undefined) {
116
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
117
+ res.end(refusal);
118
+ return;
119
+ }
120
+ // A refusal is an ANSWER. The caller is a foreground CLI waiting on this socket: a thrown
121
+ // handler that took the response down with it would hang `reticle drive` on a request
122
+ // that can never complete — the same shape of silence the raw EADDRINUSE bind had.
123
+ const payload = await provider(url).catch((err) => ({
124
+ error: err instanceof Error ? err.message : String(err),
125
+ }));
126
+ res.writeHead(200, { 'Content-Type': 'application/json' });
127
+ res.end(JSON.stringify(payload));
128
+ })
129
+ .catch((err) => {
130
+ log('drive_request_error', { error: err instanceof Error ? err.message : String(err) });
131
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
132
+ res.end('drive failed');
133
+ });
134
+ return;
135
+ }
63
136
  if ('GET' === req.method && path === MCP_SSE_PATH) {
64
137
  // The agent's MCP server announces itself here, on the connect it already makes. The daemon is
65
138
  // the single judge of skew, so this is where the third pair is decided — and unlike the CLI's
@@ -79,10 +152,12 @@ export function createSharedServer(options = {}) {
79
152
  const transport = new SSEServerTransport(MCP_MESSAGE_PATH, res);
80
153
  const sid = transport.sessionId;
81
154
  transports.set(sid, transport);
155
+ sseStreams.set(sid, res);
82
156
  if (1 === transports.size)
83
157
  agentPresence?.(true); // first agent attached
84
158
  res.on('close', () => {
85
159
  transports.delete(sid);
160
+ sseStreams.delete(sid);
86
161
  transport.close().catch(() => undefined);
87
162
  mcpServer.close().catch(() => undefined);
88
163
  log('mcp_client_disconnected', { sessionId: sid });
@@ -137,17 +212,33 @@ export function createSharedServer(options = {}) {
137
212
  function attachStatus(provider) {
138
213
  statusProvider = provider;
139
214
  }
215
+ function attachDrive(provider) {
216
+ driveProvider = provider;
217
+ }
140
218
  function attachAgentPresence(cb) {
141
219
  agentPresence = cb;
142
220
  }
143
221
  function attachMcp(factory) {
144
222
  mcpFactory = factory;
145
223
  }
224
+ function announceShutdown() {
225
+ for (const [sid, stream] of sseStreams) {
226
+ try {
227
+ stream.write(`event: ${MCP_SHUTDOWN_EVENT}\ndata: {}\n\n`);
228
+ }
229
+ catch {
230
+ // A stream we cannot write to is a proxy that already left. Announcing a shutdown must never
231
+ // be the reason a shutdown fails.
232
+ log('mcp_shutdown_announce_failed', { sessionId: sid });
233
+ }
234
+ }
235
+ }
146
236
  async function close() {
147
237
  for (const transport of transports.values()) {
148
238
  await transport.close();
149
239
  }
150
240
  transports.clear();
241
+ sseStreams.clear();
151
242
  await new Promise((resolve, reject) => {
152
243
  httpServer.close((err) => {
153
244
  // A server that never listened is already closed, and saying so is not an error worth
@@ -167,5 +258,112 @@ export function createSharedServer(options = {}) {
167
258
  });
168
259
  });
169
260
  }
170
- return { httpServer, attachMcp, attachStatus, attachAgentPresence, close };
261
+ return {
262
+ httpServer,
263
+ attachMcp,
264
+ attachStatus,
265
+ attachDrive,
266
+ attachAgentPresence,
267
+ announceShutdown,
268
+ close,
269
+ };
270
+ }
271
+ /** Cap on a drive request body. A url is short; anything larger is not one. */
272
+ const MAX_DRIVE_BODY_BYTES = 8 * 1024;
273
+ /** Collect a request body, refusing one too large to be what this route accepts. */
274
+ /**
275
+ * Is this a request a cross-origin page could NOT have sent without a preflight?
276
+ *
277
+ * The three CORS "simple" content types are the whole attack surface here, so anything that is not
278
+ * JSON is refused rather than sniffed. Parameters are allowed (`application/json; charset=utf-8`)
279
+ * because real clients send them.
280
+ */
281
+ const JSON_MEDIA_TYPE = 'application/json';
282
+ function isJsonRequest(req) {
283
+ // Read through the typed accessor rather than indexing the header bag: the index signature is
284
+ // `any`-shaped, and narrowing after an unsafe read is narrowing a lie.
285
+ const value = req.headersDistinct['content-type']?.[0];
286
+ if (undefined === value)
287
+ return false;
288
+ return JSON_MEDIA_TYPE === value.split(';')[0]?.trim().toLowerCase();
289
+ }
290
+ function readBody(req) {
291
+ return new Promise((resolve, reject) => {
292
+ let body = '';
293
+ req.setEncoding('utf8');
294
+ req.on('data', (chunk) => {
295
+ body += chunk;
296
+ if (body.length > MAX_DRIVE_BODY_BYTES) {
297
+ req.destroy();
298
+ reject(new Error('body too large'));
299
+ }
300
+ });
301
+ req.on('end', () => resolve(body));
302
+ req.on('error', reject);
303
+ });
304
+ }
305
+ /** The `url` out of a drive request body, or undefined when there isn't a usable one. Pure. */
306
+ /**
307
+ * The schemes the daemon will open in a browser it owns.
308
+ *
309
+ * `drive` exists to drive a running web app, and nothing in the product hands this route anything
310
+ * else — the desktop paths have their own runtimes and never come through here. So the allowlist
311
+ * costs nothing real and closes a class outright.
312
+ *
313
+ * Kept deliberately small. `file:` reads the disk through a browser context WE opened, `data:` is a
314
+ * page that came from nowhere, and `javascript:` is not a document at all. None of them is a thing
315
+ * anybody drives.
316
+ *
317
+ * Worth stating what this is NOT: the CORS defence on the route already keeps a web page off it,
318
+ * because a JSON content type forces a preflight nobody opts into. The caller reaching this check is
319
+ * a local process, which already has broader access than the check removes. It is hygiene about what
320
+ * the daemon will open on request, not a security boundary — and a refusal here should read as a
321
+ * refusal, not an alarm.
322
+ */
323
+ const DRIVEABLE_SCHEMES = ['http:', 'https:'];
324
+ /** The scheme of `url`, or undefined when it is not a URL at all. */
325
+ function schemeOf(url) {
326
+ try {
327
+ return new URL(url).protocol;
328
+ }
329
+ catch {
330
+ return undefined;
331
+ }
332
+ }
333
+ export function driveSchemeRefusal(url) {
334
+ const scheme = schemeOf(url);
335
+ if (scheme !== undefined && DRIVEABLE_SCHEMES.includes(scheme))
336
+ return undefined;
337
+ // Names what it got, because the caller is a CLI printing this to somebody who typed the url and
338
+ // needs to see which part of it was the problem.
339
+ return (`unsupported scheme ${scheme ?? '(unparseable url)'} — reticle drive opens a running web app, ` +
340
+ `so it accepts ${DRIVEABLE_SCHEMES.join(' and ')} only`);
341
+ }
342
+ function urlFromDriveRequest(body) {
343
+ let parsed;
344
+ try {
345
+ parsed = JSON.parse(body);
346
+ }
347
+ catch {
348
+ return undefined;
349
+ }
350
+ if (typeof parsed !== 'object' || null === parsed)
351
+ return undefined;
352
+ const url = parsed['url'];
353
+ return 'string' === typeof url && url.length > 0 ? url : undefined;
354
+ }
355
+ /**
356
+ * The one URL parse on the daemon's request path, and the only place allowed to do it.
357
+ *
358
+ * `null` for anything the parser refuses, so the caller answers rather than throwing. Exported for
359
+ * nothing — it is here, next to its single caller, because a second copy of this parse elsewhere in
360
+ * the handler would reintroduce the crash the guard exists to remove.
361
+ */
362
+ function parseRequestTarget(rawUrl) {
363
+ try {
364
+ return new URL(rawUrl, 'http://localhost');
365
+ }
366
+ catch {
367
+ return null;
368
+ }
171
369
  }
@@ -0,0 +1,23 @@
1
+ import { type ImpactCounts, type ImpactSnapshot } from '@reticlehq/core';
2
+ import { ImpactStore, type ImpactFoldMeta } from './impact-store.js';
3
+ /** Wire the recorder to a project. Idempotent; the first root wins for the daemon's lifetime. */
4
+ export declare function initImpact(opts: {
5
+ reticleRoot: string | undefined;
6
+ projectName?: string;
7
+ now?: () => number;
8
+ }): ImpactStore | undefined;
9
+ /** The live store, if one has been initialised. */
10
+ export declare function impactStore(): ImpactStore | undefined;
11
+ /** Test seam: drop the singleton so a suite can start from a clean record. */
12
+ export declare function resetImpactForTest(): void;
13
+ /** Record one delta, if the recorder is live. Never throws: stats must not break a tool call. */
14
+ export declare function recordImpact(delta: Partial<ImpactCounts>, meta?: ImpactFoldMeta): void;
15
+ export declare function impactSnapshot(): ImpactSnapshot | undefined;
16
+ /**
17
+ * What one tool call did, as counters.
18
+ *
19
+ * Pure and exported so the shape is testable without a dispatch: given a result and a duration, it
20
+ * says what the record should gain. `verified` is the only field that can mint a verdict, which is
21
+ * the same rule the product states to agents - everything else moves or reads the app.
22
+ */
23
+ export declare function deltaForToolResult(raw: unknown, durationMs: number, refused: boolean): Partial<ImpactCounts>;
@@ -0,0 +1,76 @@
1
+ import { Verified } from '@reticlehq/core';
2
+ import { estimateTokens } from '../session/output-budget.js';
3
+ import { ImpactStore } from './impact-store.js';
4
+ /**
5
+ * The daemon's single impact recorder.
6
+ *
7
+ * One store per daemon, created on the first call that knows the project's `.reticle` root. A
8
+ * module singleton for the same reason the tool counters are: the dispatch chokepoint has to be
9
+ * able to record without every tool threading a store through its arguments, and a second recorder
10
+ * would silently split the user's own history in half.
11
+ */
12
+ let store;
13
+ /** Wire the recorder to a project. Idempotent; the first root wins for the daemon's lifetime. */
14
+ export function initImpact(opts) {
15
+ // No root, no record. Programmatic callers and test doubles build their own deps and are not
16
+ // obliged to carry one, and a courtesy counter must never be the reason a tool call throws.
17
+ if (opts.reticleRoot === undefined || 0 === opts.reticleRoot.length)
18
+ return store;
19
+ store ??= new ImpactStore({ ...opts, reticleRoot: opts.reticleRoot });
20
+ return store;
21
+ }
22
+ /** The live store, if one has been initialised. */
23
+ export function impactStore() {
24
+ return store;
25
+ }
26
+ /** Test seam: drop the singleton so a suite can start from a clean record. */
27
+ export function resetImpactForTest() {
28
+ store = undefined;
29
+ }
30
+ /** Record one delta, if the recorder is live. Never throws: stats must not break a tool call. */
31
+ export function recordImpact(delta, meta = {}) {
32
+ try {
33
+ store?.record(delta, meta);
34
+ }
35
+ catch {
36
+ // Counting is a courtesy to the user; failing to count is not worth failing their call over.
37
+ }
38
+ }
39
+ export function impactSnapshot() {
40
+ return store?.snapshot();
41
+ }
42
+ /**
43
+ * What one tool call did, as counters.
44
+ *
45
+ * Pure and exported so the shape is testable without a dispatch: given a result and a duration, it
46
+ * says what the record should gain. `verified` is the only field that can mint a verdict, which is
47
+ * the same rule the product states to agents - everything else moves or reads the app.
48
+ */
49
+ export function deltaForToolResult(raw, durationMs, refused) {
50
+ const delta = {
51
+ calls: 1,
52
+ drivingMs: Math.max(0, Math.round(durationMs)),
53
+ tokensReturned: estimateTokens(JSON.stringify(raw) ?? ''),
54
+ };
55
+ if (refused) {
56
+ delta.refusals = 1;
57
+ return delta;
58
+ }
59
+ const verified = isRecord(raw) ? raw['verified'] : undefined;
60
+ if (verified === Verified.YES) {
61
+ delta.verdicts = 1;
62
+ delta.passed = 1;
63
+ }
64
+ else if (verified === Verified.NO) {
65
+ delta.verdicts = 1;
66
+ delta.failed = 1;
67
+ }
68
+ else if (verified === Verified.UNKNOWN) {
69
+ delta.verdicts = 1;
70
+ delta.unknown = 1;
71
+ }
72
+ return delta;
73
+ }
74
+ function isRecord(value) {
75
+ return 'object' === typeof value && null !== value;
76
+ }