@reticlehq/server 2.8.0 → 2.10.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 (318) 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/capsule-store.d.ts +2 -2
  7. package/dist/capsule/causal-summary.d.ts +16 -1
  8. package/dist/capsule/causal-summary.js +2 -1
  9. package/dist/cli/cli-doctor.js +41 -13
  10. package/dist/cli/cli-flow-commands.js +8 -8
  11. package/dist/cli/cli-launch.d.ts +0 -17
  12. package/dist/cli/cli-launch.js +0 -28
  13. package/dist/cli/cli-parse.d.ts +1 -1
  14. package/dist/cli/cli-parse.js +1 -1
  15. package/dist/cli/cli-port.d.ts +24 -0
  16. package/dist/cli/cli-port.js +66 -35
  17. package/dist/cli/cli-verify.d.ts +21 -0
  18. package/dist/cli/cli-verify.js +45 -1
  19. package/dist/cli/config-discovery.d.ts +35 -0
  20. package/dist/cli/config-discovery.js +163 -0
  21. package/dist/cli/doctor-daemon-line.js +4 -1
  22. package/dist/cli/doctor-project-line.d.ts +37 -0
  23. package/dist/cli/doctor-project-line.js +35 -0
  24. package/dist/cli/doctor-rows.d.ts +51 -0
  25. package/dist/cli/doctor-rows.js +54 -0
  26. package/dist/cli/doctor-sessions-line.js +6 -3
  27. package/dist/cli/drive-attach.d.ts +99 -0
  28. package/dist/cli/drive-attach.js +203 -0
  29. package/dist/cli/drive-command.d.ts +13 -0
  30. package/dist/cli/drive-command.js +103 -0
  31. package/dist/cli/open-note.d.ts +20 -0
  32. package/dist/cli/open-note.js +37 -0
  33. package/dist/cli/status-next-action.d.ts +37 -0
  34. package/dist/cli/status-next-action.js +61 -0
  35. package/dist/cli.js +65 -70
  36. package/dist/crawl/crawl.d.ts +7 -2
  37. package/dist/crawl/crawl.js +52 -9
  38. package/dist/ee/audit-log.js +2 -2
  39. package/dist/events/body-failures.d.ts +2 -2
  40. package/dist/events/contradiction-folds.d.ts +45 -0
  41. package/dist/events/contradiction-folds.js +42 -0
  42. package/dist/events/contradictions.d.ts +51 -1
  43. package/dist/events/contradictions.js +26 -7
  44. package/dist/events/declared.d.ts +45 -0
  45. package/dist/events/declared.js +73 -0
  46. package/dist/events/echo-mismatch.d.ts +9 -3
  47. package/dist/events/echo-mismatch.js +33 -5
  48. package/dist/events/event-filters.d.ts +1 -1
  49. package/dist/events/event-filters.js +7 -4
  50. package/dist/events/predicate-eval.d.ts +17 -91
  51. package/dist/events/predicate-eval.js +85 -326
  52. package/dist/events/predicate-parse.js +31 -3
  53. package/dist/events/predicate-route.d.ts +32 -0
  54. package/dist/events/predicate-route.js +102 -0
  55. package/dist/events/predicate-schema.d.ts +99 -0
  56. package/dist/events/predicate-schema.js +309 -0
  57. package/dist/events/predicate.d.ts +8 -0
  58. package/dist/events/predicate.js +127 -5
  59. package/dist/events/stale-response.d.ts +2 -3
  60. package/dist/events/stale-response.js +49 -2
  61. package/dist/events/unit-mismatch.d.ts +2 -2
  62. package/dist/flows/annotate-tools.js +3 -0
  63. package/dist/flows/annotate.js +18 -4
  64. package/dist/flows/coverage.d.ts +38 -5
  65. package/dist/flows/coverage.js +30 -4
  66. package/dist/flows/decision.d.ts +15 -0
  67. package/dist/flows/decision.js +1 -1
  68. package/dist/flows/flow-replay-run.js +8 -1
  69. package/dist/flows/flow-tools.js +9 -1
  70. package/dist/flows/replay.js +3 -0
  71. package/dist/flows/verify-change-tools.js +1 -1
  72. package/dist/honesty/accepted-write.d.ts +3 -0
  73. package/dist/honesty/accepted-write.js +8 -3
  74. package/dist/honesty/already-true.js +15 -0
  75. package/dist/honesty/blind-spots.js +4 -0
  76. package/dist/honesty/honesty.d.ts +11 -0
  77. package/dist/honesty/honesty.js +1 -0
  78. package/dist/honesty/uncaptured-bodies.d.ts +4 -2
  79. package/dist/honesty/uncaptured-bodies.js +9 -2
  80. package/dist/honesty/unread-outcome.d.ts +8 -0
  81. package/dist/honesty/unread-outcome.js +36 -9
  82. package/dist/honesty/unsettled.d.ts +9 -0
  83. package/dist/honesty/unsettled.js +7 -3
  84. package/dist/honesty/verified.d.ts +29 -3
  85. package/dist/honesty/verified.js +67 -8
  86. package/dist/http-server.d.ts +19 -0
  87. package/dist/http-server.js +202 -4
  88. package/dist/impact/impact-recorder.d.ts +23 -0
  89. package/dist/impact/impact-recorder.js +76 -0
  90. package/dist/impact/impact-store.d.ts +47 -0
  91. package/dist/impact/impact-store.js +171 -0
  92. package/dist/index.d.ts +25 -0
  93. package/dist/index.js +60 -10
  94. package/dist/init/agent-rules.d.ts +20 -0
  95. package/dist/init/agent-rules.js +36 -0
  96. package/dist/init/astro-patch.js +27 -1
  97. package/dist/init/confirm.d.ts +21 -3
  98. package/dist/init/confirm.js +32 -5
  99. package/dist/init/connect-steps.d.ts +2 -0
  100. package/dist/init/connect-steps.js +50 -0
  101. package/dist/init/csp-check.d.ts +38 -0
  102. package/dist/init/csp-check.js +92 -0
  103. package/dist/init/csp-doctor.d.ts +25 -0
  104. package/dist/init/csp-doctor.js +49 -0
  105. package/dist/init/cursor.js +6 -9
  106. package/dist/init/detect.d.ts +8 -0
  107. package/dist/init/detect.js +33 -3
  108. package/dist/init/mcp-clients.d.ts +1 -0
  109. package/dist/init/mcp-clients.js +51 -7
  110. package/dist/init/mcp.d.ts +16 -2
  111. package/dist/init/mcp.js +23 -1
  112. package/dist/init/plan-framework.d.ts +10 -0
  113. package/dist/init/plan-framework.js +25 -1
  114. package/dist/init/plan.d.ts +7 -2
  115. package/dist/init/plan.js +66 -30
  116. package/dist/init/run.js +11 -5
  117. package/dist/init/slash-command.d.ts +1 -1
  118. package/dist/init/slash-command.js +12 -4
  119. package/dist/init/snippets.d.ts +1 -1
  120. package/dist/init/snippets.js +35 -7
  121. package/dist/init/vite-config.js +24 -2
  122. package/dist/input/network-mock-tools.js +8 -1
  123. package/dist/input/scroll-find.d.ts +6 -0
  124. package/dist/input/scroll-find.js +17 -2
  125. package/dist/input/scroll-tools.js +4 -0
  126. package/dist/journal/attach-journal.d.ts +9 -1
  127. package/dist/journal/attach-journal.js +13 -2
  128. package/dist/journal/journal-query.d.ts +13 -0
  129. package/dist/journal/journal-query.js +28 -3
  130. package/dist/journal/workspace-gitignore.d.ts +10 -0
  131. package/dist/journal/workspace-gitignore.js +59 -0
  132. package/dist/license/license.d.ts +69 -4
  133. package/dist/license/license.js +84 -15
  134. package/dist/mcp/mcp-proxy.d.ts +7 -93
  135. package/dist/mcp/mcp-proxy.js +183 -250
  136. package/dist/mcp/mcp.d.ts +27 -2
  137. package/dist/mcp/mcp.js +77 -36
  138. package/dist/mcp/proxy-daemon-probe.d.ts +8 -0
  139. package/dist/mcp/proxy-daemon-probe.js +48 -0
  140. package/dist/mcp/proxy-handshake.d.ts +82 -1
  141. package/dist/mcp/proxy-handshake.js +110 -3
  142. package/dist/mcp/proxy-lifecycle.d.ts +10 -1
  143. package/dist/mcp/proxy-lifecycle.js +11 -2
  144. package/dist/mcp/proxy-log.d.ts +63 -0
  145. package/dist/mcp/proxy-log.js +132 -0
  146. package/dist/mcp/server-instructions.d.ts +17 -0
  147. package/dist/mcp/server-instructions.js +56 -0
  148. package/dist/mcp/shared-params.d.ts +27 -0
  149. package/dist/mcp/shared-params.js +36 -0
  150. package/dist/mcp/sse-frame-parser.d.ts +19 -0
  151. package/dist/mcp/sse-frame-parser.js +41 -0
  152. package/dist/project/reticle-dir.d.ts +2 -0
  153. package/dist/project/reticle-dir.js +1 -0
  154. package/dist/session/connection-memory.d.ts +59 -0
  155. package/dist/session/connection-memory.js +108 -0
  156. package/dist/session/dev-command.d.ts +26 -0
  157. package/dist/session/dev-command.js +89 -0
  158. package/dist/session/dev-server-probe.d.ts +68 -0
  159. package/dist/session/dev-server-probe.js +102 -7
  160. package/dist/session/last-act.d.ts +26 -0
  161. package/dist/session/last-act.js +35 -1
  162. package/dist/session/live-control-tools.js +42 -5
  163. package/dist/session/no-session-diagnosis.d.ts +55 -2
  164. package/dist/session/no-session-diagnosis.js +199 -22
  165. package/dist/session/no-session-next-action.d.ts +46 -0
  166. package/dist/session/no-session-next-action.js +85 -0
  167. package/dist/session/no-session-watch.d.ts +40 -1
  168. package/dist/session/no-session-watch.js +172 -11
  169. package/dist/session/pending-commands.d.ts +12 -0
  170. package/dist/session/pending-commands.js +13 -1
  171. package/dist/session/session-manager.d.ts +4 -0
  172. package/dist/session/session-manager.js +35 -5
  173. package/dist/session/session-readiness.js +1 -1
  174. package/dist/session/session.d.ts +10 -1
  175. package/dist/session/session.js +146 -5
  176. package/dist/telemetry/app-instrumented.d.ts +8 -0
  177. package/dist/telemetry/app-instrumented.js +10 -0
  178. package/dist/telemetry/bug-found.d.ts +18 -0
  179. package/dist/telemetry/bug-found.js +14 -0
  180. package/dist/telemetry/daemon-telemetry.js +29 -0
  181. package/dist/telemetry/install-source.d.ts +20 -0
  182. package/dist/telemetry/install-source.js +34 -0
  183. package/dist/telemetry/instrumentation-stall.d.ts +57 -0
  184. package/dist/telemetry/instrumentation-stall.js +103 -0
  185. package/dist/telemetry/license-activation.d.ts +48 -0
  186. package/dist/telemetry/license-activation.js +57 -0
  187. package/dist/telemetry/session-metrics.d.ts +8 -0
  188. package/dist/telemetry/session-metrics.js +10 -0
  189. package/dist/telemetry/telemetry.d.ts +3 -1
  190. package/dist/telemetry/telemetry.js +23 -3
  191. package/dist/tools/act-tools.js +166 -29
  192. package/dist/tools/assert-verdict.js +32 -7
  193. package/dist/tools/browser-tools.js +10 -0
  194. package/dist/tools/dynamic-tools.js +17 -8
  195. package/dist/tools/error-recovery.js +10 -6
  196. package/dist/tools/feedback-tools.js +31 -6
  197. package/dist/tools/invoke-tool.d.ts +1 -1
  198. package/dist/tools/invoke-tool.js +30 -8
  199. package/dist/tools/lease-hint.d.ts +30 -8
  200. package/dist/tools/lease-hint.js +65 -14
  201. package/dist/tools/lease-tools.d.ts +11 -0
  202. package/dist/tools/lease-tools.js +141 -96
  203. package/dist/tools/observe-tools.js +9 -3
  204. package/dist/tools/react-grace.d.ts +4 -0
  205. package/dist/tools/react-grace.js +6 -3
  206. package/dist/tools/read-tools.js +47 -1
  207. package/dist/tools/resolve-target.d.ts +33 -0
  208. package/dist/tools/resolve-target.js +53 -0
  209. package/dist/tools/settle-in-flight.d.ts +15 -0
  210. package/dist/tools/settle-in-flight.js +42 -2
  211. package/dist/tools/snapshot-delta.d.ts +3 -0
  212. package/dist/tools/snapshot-delta.js +67 -6
  213. package/dist/tools/tool-kit.d.ts +38 -3
  214. package/dist/tools/tool-names.d.ts +2 -0
  215. package/dist/tools/tool-names.js +2 -0
  216. package/dist/tools/tool-surface.d.ts +60 -0
  217. package/dist/tools/tool-surface.js +84 -19
  218. package/dist/tools/tools.js +61 -5
  219. package/dist/tools/unadvertised-help.js +9 -6
  220. package/docs/README.md +1 -2
  221. package/docs/actions.mdx +41 -8
  222. package/docs/agent-cheatsheet.md +6 -4
  223. package/docs/architecture.md +6 -0
  224. package/docs/benchmarks.md +60 -12
  225. package/docs/best-practices.mdx +11 -6
  226. package/docs/capabilities.mdx +204 -57
  227. package/docs/cli/affected.mdx +6 -4
  228. package/docs/cli/capsules.mdx +9 -8
  229. package/docs/cli/cloud.mdx +30 -4
  230. package/docs/cli/doctor.mdx +41 -8
  231. package/docs/cli/drive.mdx +34 -15
  232. package/docs/cli/gate.mdx +6 -6
  233. package/docs/cli/help.mdx +8 -6
  234. package/docs/cli/hunt.mdx +20 -4
  235. package/docs/cli/identify.mdx +16 -2
  236. package/docs/cli/init.mdx +35 -8
  237. package/docs/cli/kill.mdx +5 -5
  238. package/docs/cli/license.mdx +1 -1
  239. package/docs/cli/open.mdx +6 -0
  240. package/docs/cli/restart.mdx +6 -6
  241. package/docs/cli/serve.mdx +10 -3
  242. package/docs/cli/status.mdx +29 -5
  243. package/docs/cli/stop.mdx +8 -0
  244. package/docs/cli/verify.mdx +20 -1
  245. package/docs/cli/version.mdx +1 -1
  246. package/docs/cli/watch.mdx +9 -2
  247. package/docs/cli.mdx +44 -22
  248. package/docs/debugging.md +3 -1
  249. package/docs/deploy-checks.md +31 -2
  250. package/docs/desktop.mdx +276 -80
  251. package/docs/docs.json +13 -12
  252. package/docs/enterprise.md +9 -3
  253. package/docs/faq.mdx +49 -35
  254. package/docs/fixtures.md +2 -0
  255. package/docs/flows.md +7 -5
  256. package/docs/for-agents.md +3 -3
  257. package/docs/frameworks.mdx +46 -7
  258. package/docs/gate-plan.md +4 -2
  259. package/docs/gates.md +8 -6
  260. package/docs/getting-started.md +53 -12
  261. package/docs/human-control.md +2 -0
  262. package/docs/index.mdx +43 -1
  263. package/docs/install-agentic.mdx +13 -4
  264. package/docs/install-manual.mdx +6 -4
  265. package/docs/instrumentation.mdx +7 -1
  266. package/docs/integration-patterns.md +3 -1
  267. package/docs/local-registry.md +4 -2
  268. package/docs/multi-agent-testing.md +3 -1
  269. package/docs/packages/babel-plugin.mdx +15 -6
  270. package/docs/packages/browser.mdx +2 -2
  271. package/docs/packages/core.mdx +26 -16
  272. package/docs/packages/electron.mdx +5 -7
  273. package/docs/packages/eslint-plugin.mdx +5 -3
  274. package/docs/packages/next.mdx +3 -5
  275. package/docs/packages/react.mdx +3 -3
  276. package/docs/packages/server.mdx +2 -2
  277. package/docs/packages/tauri.mdx +6 -6
  278. package/docs/packages/test.mdx +18 -7
  279. package/docs/packages/vite-plugin.mdx +4 -6
  280. package/docs/packages.mdx +14 -13
  281. package/docs/platform-integration.md +2 -0
  282. package/docs/predicates.mdx +76 -7
  283. package/docs/quickstart.mdx +51 -5
  284. package/docs/recipes.mdx +63 -23
  285. package/docs/skill-file.mdx +3 -1
  286. package/docs/state-management.mdx +2 -0
  287. package/docs/system-map.md +3 -1
  288. package/docs/telemetry-contract.md +33 -5
  289. package/docs/telemetry-events.mdx +63 -18
  290. package/docs/telemetry.md +8 -5
  291. package/docs/testing.md +35 -1
  292. package/docs/token-efficiency.md +8 -2
  293. package/docs/tools-act-and-wait.mdx +64 -14
  294. package/docs/tools-act-sequence.mdx +34 -17
  295. package/docs/tools-act.mdx +24 -11
  296. package/docs/tools-assert.mdx +69 -10
  297. package/docs/tools-console.mdx +11 -7
  298. package/docs/tools-inspect.mdx +11 -8
  299. package/docs/tools-navigate.mdx +41 -5
  300. package/docs/tools-network.mdx +42 -9
  301. package/docs/tools-observe.mdx +66 -10
  302. package/docs/tools-overview.mdx +26 -23
  303. package/docs/tools-query.mdx +52 -8
  304. package/docs/tools-session-and-feedback.mdx +35 -9
  305. package/docs/tools-sessions.mdx +39 -10
  306. package/docs/tools-snapshot.mdx +57 -3
  307. package/docs/tools-state.mdx +24 -1
  308. package/docs/tools-tools-and-run.mdx +55 -24
  309. package/docs/tools-wait-for.mdx +34 -15
  310. package/docs/troubleshooting.mdx +80 -10
  311. package/docs/usage.md +46 -24
  312. package/docs/vs-chrome-devtools-mcp.mdx +41 -0
  313. package/docs/vs-playwright-mcp.mdx +42 -2
  314. package/docs/vs-screenshots.mdx +42 -1
  315. package/docs/what-is-reticle.mdx +1 -1
  316. package/docs/why-reticle.mdx +3 -1
  317. package/package.json +5 -5
  318. package/docs/desktop-apps.md +0 -265
@@ -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
@@ -103,7 +133,23 @@ export function decideVerified(inputs) {
103
133
  // A vacuous green: nothing was actually proved. Grade NONE means no signal, no request, no state
104
134
  // change and no element was pinned — the assertion could not have failed, which makes passing it
105
135
  // evidence of nothing.
136
+ // Two different facts used to share this answer. Everything above has already ruled out a failure,
137
+ // a contradiction, a dirty capture, a pending write and an unread one — so when the window ALSO
138
+ // settled, the engine did not fail to see: it saw the whole window, ran every oracle, and found
139
+ // nothing wrong. That is `no-fault`, and telling it apart from "I could not see" is the difference
140
+ // between "assert something" and "look again with better coverage".
141
+ //
142
+ // Gated on `true === settled` rather than on a caller's say-so. A call that returned before the app
143
+ // stopped moving has not earned this, whatever it declared — which is what stops `no-fault` from
144
+ // becoming the green-forever button an always-available "nothing was wrong" would be.
106
145
  if (honesty.grade === HonestyGrade.NONE) {
146
+ if (true === settled) {
147
+ return {
148
+ verified: Verified.NO_FAULT,
149
+ verifiedReason: VerifiedReason.NOTHING_DECLARED,
150
+ because: 'the page settled and no channel reported a problem, but nothing was declared to prove — this is not verification. Assert a consequence the action CHANGES (a signal, a request, a route, or store state) to get one',
151
+ };
152
+ }
107
153
  return {
108
154
  verified: Verified.UNKNOWN,
109
155
  verifiedReason: VerifiedReason.VACUOUS_GRADE,
@@ -132,15 +178,22 @@ export function decideVerified(inputs) {
132
178
  //
133
179
  // UNKNOWN, not NO: nothing is known to have failed. The remedy is in the sentence, because an
134
180
  // agent that cannot act on a caveat will learn to skip it.
135
- if (true === outcomeUnread) {
181
+ if (outcomeUnread !== undefined && outcomeUnread.length > 0) {
136
182
  return {
137
183
  verified: Verified.UNKNOWN,
138
184
  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',
185
+ because: `a write returned 2xx with a response body that was never recorded (${outcomeUnread.join('; ')}), so its outcome is unread` +
186
+ ' — 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
187
  };
141
188
  }
142
189
  // Never settled: the page may still be moving, so the observation window may have closed early.
143
- if (false === settled) {
190
+ //
191
+ // Skipped when the caller DECLARED the consequence and it held. `settled` here is "a real
192
+ // animation frame fired within the budget", which a hidden or throttled tab never does — and a
193
+ // hidden tab is the ordinary case for agent-driven verification. It is corroboration; it is not
194
+ // the claim. It is still reported: `honesty.settled` carries the fact, and the sentence below says
195
+ // so in words.
196
+ if (false === settled && !declaredHeld) {
144
197
  return {
145
198
  verified: Verified.UNKNOWN,
146
199
  verifiedReason: VerifiedReason.UNSETTLED,
@@ -155,11 +208,17 @@ export function decideVerified(inputs) {
155
208
  // partial` — the prose contradicting the evidence block next to it. Whichever a reader believes,
156
209
  // one of them lied, and the whole point of this layer is that the sentence can be trusted on its
157
210
  // own. Seen on a one-way IPC send: coverage said the outcome was unobservable, `because` said clean.
211
+ // A green that rests on the declaration rather than on idle says so. The page not going quiet is a
212
+ // real fact about the app — a poll, a timer, a throttled tab — and a caller who cares must be able
213
+ // to see it without re-deriving it, so it is stated here and carried in `honesty.settled`.
214
+ const notIdle = false === settled
215
+ ? ', though the page never went idle — this rests on the consequence you declared, not on the page going quiet'
216
+ : '';
158
217
  return {
159
218
  verified: Verified.YES,
160
219
  verifiedReason: VerifiedReason.PROVED,
161
220
  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`,
221
+ ? `assertion held at ${honesty.grade} grade with no channel disagreeing, but coverage was PARTIAL — see \`coverage\` for what went unobserved${notIdle}`
222
+ : `assertion held at ${honesty.grade} grade over a clean capture with no channel disagreeing${notIdle}`,
164
223
  };
165
224
  }
@@ -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
+ }
@@ -0,0 +1,47 @@
1
+ import { type ImpactCounts, type ImpactScope, type ImpactSnapshot } from '@reticlehq/core';
2
+ export interface ImpactPaths {
3
+ project: string;
4
+ global: string;
5
+ }
6
+ /**
7
+ * `reticleRoot` is the project's own `.reticle` directory (what every other store here is handed);
8
+ * the global scope always lives beside the daemon's own state in `~/.reticle`.
9
+ */
10
+ export declare function impactPaths(reticleRoot: string): ImpactPaths;
11
+ /** Read a scope, tolerating absence and corruption: a broken file starts over rather than throwing. */
12
+ export declare function readScope(path: string, now: number): ImpactScope;
13
+ /** YYYY-MM-DD in local time - the day boundary a person recognises, not UTC's. */
14
+ export declare function isoDay(now: number): string;
15
+ /**
16
+ * What a fold knows beyond the counters.
17
+ *
18
+ * `runMs` is a SESSION's lifetime, and it only ever feeds the "longest run" record. It is not a
19
+ * counter: adding it to a total would answer a question nobody asked, and the record used to be fed
20
+ * a single tool call's duration instead, which made the report's superlative a number of
21
+ * milliseconds that never grew past one click.
22
+ */
23
+ export interface ImpactFoldMeta {
24
+ runMs?: number;
25
+ }
26
+ /** Fold one delta into a scope: totals, today's bucket, records and streak. Pure. */
27
+ export declare function applyDelta(scope: ImpactScope, delta: Partial<ImpactCounts>, now: number, meta?: ImpactFoldMeta): ImpactScope;
28
+ /**
29
+ * The live impact record for one project.
30
+ *
31
+ * Holds both scopes in memory, folds deltas in synchronously (so a reader always sees the truth),
32
+ * and flushes to disk on a debounce.
33
+ */
34
+ export declare class ImpactStore {
35
+ #private;
36
+ constructor(opts: {
37
+ reticleRoot: string;
38
+ projectName?: string;
39
+ now?: () => number;
40
+ });
41
+ /** Notified after every fold, so the HUD can be pushed a fresh snapshot. */
42
+ onChange(fn: () => void): void;
43
+ record(delta: Partial<ImpactCounts>, meta?: ImpactFoldMeta): void;
44
+ snapshot(): ImpactSnapshot;
45
+ /** Write now (process exit, tests). */
46
+ flush(): void;
47
+ }