@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
@@ -5,7 +5,8 @@ import { selectPath, capDepth } from '../session/state-select.js';
5
5
  import { describeTestidMiss } from './testid-near-miss.js';
6
6
  import { predicateToExpectedLinks } from '../capsule/predicate-to-links.js';
7
7
  import { isAmbient, ambientKeyOf } from '../journal/ambient.js';
8
- import { PredicateSchema, matchValue, evalNet, evalRoute, evalConsole, evalAnimation, evalSignal, evalSettled, residualQueryChecks, satisfiesResiduals, describeResidual, } from './predicate-eval.js';
8
+ import { evalRoute } from './predicate-route.js';
9
+ import { PredicateSchema, matchValue, evalNet, evalConsole, evalAnimation, evalSignal, evalSettled, residualQueryChecks, satisfiesResiduals, describeResidual, } from './predicate-eval.js';
9
10
  export { PredicateSchema, PredicateKind };
10
11
  /**
11
12
  * A composite result that carries a child's "nobody could evaluate this" up to the verdict rule.
@@ -189,8 +190,107 @@ function asSelection(result) {
189
190
  ? { found: record.found, value: record.value }
190
191
  : undefined;
191
192
  }
193
+ /**
194
+ * A NAMED store needs no whole-store read. `reticle_state`'s scoped mode selects the path out of the
195
+ * RAW store IN-PAGE and returns only it, so the assertion resolves in one round trip against a payload
196
+ * the size of the value — not the store. The unnamed case below still needs the wide read, because
197
+ * that is how it discovers WHICH store carries the path; only the named branch changes.
198
+ *
199
+ * A scoped read answers `found: false` for both "no such store" and "no such path", so the two are
200
+ * kept distinct here using the `storeNames` list the reply always carries — otherwise the payload gets
201
+ * cheaper while the message gets worse.
202
+ */
203
+ async function evalStateNamed(session, p, storeName) {
204
+ const scoped = await session.command(ReticleCommand.STATE_READ, {
205
+ store: storeName,
206
+ path: p.path,
207
+ });
208
+ if (!scoped.ok) {
209
+ return {
210
+ pass: false,
211
+ failureReason: 'state read failed',
212
+ observed: 'the store could not be read',
213
+ expected: 'a readable registered store',
214
+ assertion: 'state.unreadable',
215
+ };
216
+ }
217
+ const reply = (scoped.result ?? {});
218
+ const names = Array.isArray(reply.storeNames) ? reply.storeNames : [];
219
+ // Resolve the value. A CURRENT SDK honours the scoped read and answers `{ found, value }` selected
220
+ // in-page — the whole point of this path. An OLDER SDK (version skew) or any transport that ignores
221
+ // `path` answers the whole-store shape `{ stores }`; walk the path server-side there so the verdict
222
+ // stays correct across SDK versions. The scoped win is simply unavailable on the old ones.
223
+ const scopedSel = asSelection(scoped.result);
224
+ const wholeStores = reply.stores;
225
+ const scopedKeys = Array.isArray(reply.availableKeys)
226
+ ? reply.availableKeys
227
+ : undefined;
228
+ let selection;
229
+ if (scopedSel !== undefined) {
230
+ selection =
231
+ scopedKeys === undefined
232
+ ? { found: scopedSel.found, value: scopedSel.value }
233
+ : { found: scopedSel.found, value: scopedSel.value, availableKeys: scopedKeys };
234
+ }
235
+ else if (wholeStores !== undefined) {
236
+ selection = selectPath(wholeStores[storeName], p.path);
237
+ }
238
+ else {
239
+ selection = { found: false };
240
+ }
241
+ // "no store named X" and "X has no such path" both surface as found:false; keep them distinct using
242
+ // the store list the reply carries, or the message regresses while the payload improves.
243
+ const storeAbsent = wholeStores !== undefined
244
+ ? !(storeName in wholeStores)
245
+ : names.length > 0 && !names.includes(storeName);
246
+ if (!selection.found && storeAbsent) {
247
+ return {
248
+ pass: false,
249
+ failureReason: `no store named '${storeName}' is registered (${names.join(', ')})`,
250
+ observed: `store '${storeName}' is not registered`,
251
+ expected: `a registered store named '${storeName}'`,
252
+ assertion: 'state.store-missing',
253
+ evidence: { searchedStores: names },
254
+ };
255
+ }
256
+ // A scoped sub-tree can itself be too big for the caps; a comparison against a value known to be
257
+ // incomplete is an unanswered question, not a failure. Same rule the whole-store path applies.
258
+ if (truncationOf(scoped.result) !== undefined) {
259
+ const reason = `state '${p.path}' could not be read intact — the transport caps truncated it, so the ` +
260
+ 'value was never compared. Assert a narrower path, or a smaller field inside it';
261
+ return { pass: false, failureReason: reason, inconclusive: reason };
262
+ }
263
+ if (!selection.found) {
264
+ return {
265
+ pass: false,
266
+ failureReason: `state path '${p.path}' not found in store '${storeName}'`,
267
+ observed: `no path '${p.path}' in store '${storeName}'`,
268
+ expected: `store '${storeName}' to expose '${p.path}'`,
269
+ assertion: 'state.path-missing',
270
+ evidence: { availableKeys: selection.availableKeys },
271
+ };
272
+ }
273
+ const want = p.equals === undefined ? '*' : p.equals;
274
+ if (matchValue(selection.value, want)) {
275
+ return {
276
+ pass: true,
277
+ evidence: { store: storeName, path: p.path, value: capDepth(selection.value, 1) },
278
+ };
279
+ }
280
+ return {
281
+ pass: false,
282
+ failureReason: `state '${p.path}' is ${JSON.stringify(capDepth(selection.value, 0))}, expected ${JSON.stringify(want)}`,
283
+ observed: `${p.path} = ${JSON.stringify(capDepth(selection.value, 0))}`,
284
+ expected: `${p.path} = ${JSON.stringify(want)}`,
285
+ assertion: 'state.equals',
286
+ evidence: { store: storeName, path: p.path, value: capDepth(selection.value, 1) },
287
+ };
288
+ }
192
289
  async function evalState(session, p) {
193
- const res = await session.command(ReticleCommand.STATE_READ, p.store !== undefined ? { store: p.store } : {});
290
+ // Named store: one scoped read, no whole-store payload (issue #336).
291
+ if (p.store !== undefined)
292
+ return await evalStateNamed(session, p, p.store);
293
+ const res = await session.command(ReticleCommand.STATE_READ, {});
194
294
  if (!res.ok) {
195
295
  return {
196
296
  pass: false,
@@ -316,11 +416,16 @@ diagnose = true) {
316
416
  case PredicateKind.ELEMENT:
317
417
  return evalElement(session, predicate.query, predicate.state, predicate.absent ?? false, diagnose);
318
418
  case PredicateKind.TEXT:
319
- return evalElement(session, { text: predicate.contains }, true === predicate.visible ? ElementState.VISIBLE : undefined, predicate.absent ?? false, diagnose);
419
+ return evalElement(session,
420
+ // `scope` passes straight through: the text predicate has always been an element query
421
+ // with only `text` filled in, so scoping it needs the field, not a second code path.
422
+ undefined === predicate.scope
423
+ ? { text: predicate.contains }
424
+ : { text: predicate.contains, scope: predicate.scope }, true === predicate.visible ? ElementState.VISIBLE : undefined, predicate.absent ?? false, diagnose);
320
425
  case PredicateKind.NET:
321
426
  return evalNet(events, predicate);
322
427
  case PredicateKind.ROUTE:
323
- return evalRoute(events, predicate);
428
+ return evalRoute(events, predicate, session.url);
324
429
  case PredicateKind.CONSOLE:
325
430
  return evalConsole(events, predicate);
326
431
  case PredicateKind.ANIMATION:
@@ -348,6 +453,10 @@ diagnose = true) {
348
453
  return {
349
454
  pass: false,
350
455
  failureReason: failed.failureReason ?? 'a sub-predicate of allOf failed',
456
+ // A conjunction is decided as soon as ONE clause is: nothing the others do later can
457
+ // rescue it. This is what makes the early exit reach real calls, since an exact count is
458
+ // usually asserted alongside the UI change it is meant to accompany.
459
+ ...(true === failed.decided ? { decided: true } : {}),
351
460
  evidence: results,
352
461
  };
353
462
  }
@@ -376,9 +485,14 @@ diagnose = true) {
376
485
  // You cannot negate an answer nobody had.
377
486
  if (inner.inconclusive !== undefined)
378
487
  return unreadableComposite(inner, inner);
488
+ // A green negation used to return a bare `{ pass: true }`, so the payload fell back to
489
+ // whatever the caller had — every element matching the OUTER locator. Three clauses negating
490
+ // three different names then produced byte-identical responses, which reads as a checker that
491
+ // dropped the name rather than one that correctly found all three absent. The evidence is the
492
+ // whole answer here: what was looked for, and that it was not there.
379
493
  return inner.pass
380
494
  ? { pass: false, failureReason: 'negated predicate unexpectedly held', evidence: inner }
381
- : { pass: true };
495
+ : { pass: true, evidence: { negated: predicate.predicate, held: false, saw: inner } };
382
496
  }
383
497
  default:
384
498
  return { pass: false, failureReason: 'unknown predicate' };
@@ -494,6 +608,14 @@ export function waitForPredicate(session, predicate, timeoutMs, since = 0) {
494
608
  void evaluatePredicate(session, predicate, since, false)
495
609
  .then((r) => {
496
610
  if (!r.pass) {
611
+ // Final already: stop rather than spend a budget that cannot change the answer. Only
612
+ // where the evaluator could PROVE it (see EvalResult.decided) — an ordinary miss keeps
613
+ // waiting, because "it has not happened yet" and "it will not happen" are the same
614
+ // reading until the budget ends.
615
+ if (true === r.decided) {
616
+ finish(r);
617
+ return;
618
+ }
497
619
  // A time-based failure knows when it could stop being one — re-check THEN rather than on
498
620
  // the next blind tick. Without this, every `settled` wait paid up to a full poll interval
499
621
  // of dead time after the quiet window had already closed: measured at 566–627ms across
@@ -118,6 +118,9 @@ export const ANNOTATE_TOOLS = [
118
118
  recovery: z.string().optional(),
119
119
  target: z.string().optional(),
120
120
  compiled: z.string().optional(),
121
+ // Set when the compiled annotation would overstate what a replay can check — e.g. a
122
+ // success-state that flow_save will grade assertion-free (#395).
123
+ note: z.string().optional(),
121
124
  code: z.string().optional(),
122
125
  },
123
126
  handler: (deps, args) => {
@@ -1,4 +1,4 @@
1
- import { AnnotationErrorCode, AnnotationKind, AnnotationTarget, COMPILED_PREDICATE_PREFIX, } from '@reticlehq/core';
1
+ import { AnnotationErrorCode, AnnotationKind, AnnotationTarget, COMPILED_PREDICATE_PREFIX, flowExpectHasConsequence, flowExpectIsPresenceOnly, } from '@reticlehq/core';
2
2
  /**
3
3
  * The PURE compiler at the heart of the annotation facet. A structured annotation +
4
4
  * the current captured-step count → an AnnotateOutcome (a result envelope + the patch to apply to
@@ -96,11 +96,25 @@ function stepPatch(a, stepCount, stepExpect) {
96
96
  patch: { stepIndex: stepCount - 1, stepExpect },
97
97
  };
98
98
  }
99
+ // A success end-condition that is not an observable CONSEQUENCE (signal / net / state) cannot lift
100
+ // the flow above the grade flow_save will give it. Saying "will succeed when ..." without that
101
+ // caveat is the #395 contradiction: annotate promises a check that flow_save then grades
102
+ // assertion-free, so a flow that only checks the console replays green through any regression. Name
103
+ // the grade here, using the same consequence/presence classification every grader shares.
104
+ const SUCCESS_ASSERTION_FREE_NOTE = 'This success-state is not an observable consequence, so flow_save will grade the flow assertion-free — it passes even when the feature is broken. Use a signal / net / state success-state to give the flow something that can fail.';
105
+ const SUCCESS_PRESENCE_ONLY_NOTE = 'This success-state only checks element presence, so flow_save will grade the flow presence-only — a locator healed to the wrong element still passes it. Use a signal / net / state success-state to assert an observable consequence.';
99
106
  function flowSuccess(a, success) {
100
- return {
101
- result: { ok: true, target: AnnotationTarget.FLOW, compiled: describeCompiled(a) },
102
- patch: { success },
107
+ const result = {
108
+ ok: true,
109
+ target: AnnotationTarget.FLOW,
110
+ compiled: describeCompiled(a),
103
111
  };
112
+ if (!flowExpectHasConsequence(success)) {
113
+ result.note = flowExpectIsPresenceOnly(success)
114
+ ? SUCCESS_PRESENCE_ONLY_NOTE
115
+ : SUCCESS_ASSERTION_FREE_NOTE;
116
+ }
117
+ return { result, patch: { success } };
104
118
  }
105
119
  /**
106
120
  * Human-readable confirmation text for the recorder strip / tool result,
@@ -9,11 +9,18 @@ interface DeclaredSurface {
9
9
  signals: readonly string[];
10
10
  flows: readonly string[];
11
11
  }
12
- interface CoverageDimension {
12
+ export interface CoverageDimension {
13
13
  total: number;
14
14
  covered: number;
15
- /** Percent covered, rounded; 100 when nothing is declared (vacuously complete). */
16
- pct: number;
15
+ /**
16
+ * Percent covered, rounded. PRESENT ONLY WHEN IT WAS MEASURED — absent when nothing is declared,
17
+ * same rule as the honesty layer's `{ pct?: number; partial: boolean }`.
18
+ *
19
+ * This used to report 100 for an empty surface ("vacuously complete"), which made
20
+ * `covered: 0, total: 0, pct: 100` — a contradiction the caller had to notice unaided, in output an
21
+ * agent reads to decide whether its change is covered.
22
+ */
23
+ pct?: number;
17
24
  /** Declared-but-never-exercised members — what "everything" is silently missing. */
18
25
  uncovered: string[];
19
26
  }
@@ -21,8 +28,34 @@ export interface Coverage {
21
28
  testids: CoverageDimension;
22
29
  signals: CoverageDimension;
23
30
  flows: CoverageDimension;
24
- /** Overall percent across all three dimensions combined. */
25
- overallPct: number;
31
+ /** Overall percent across all three dimensions combined; absent when nothing is declared. */
32
+ overallPct?: number;
26
33
  }
34
+ /** The gate's outcome when the project has recorded no flows at all — not a pass, and not a failure. */
35
+ export declare const NO_FLOWS = "no_flows";
36
+ /**
37
+ * What to do about it, since the empty state is otherwise indistinguishable from success.
38
+ *
39
+ * Names the TOOLS, not a `reticle record` CLI verb: this CLI does not dispatch one, and a suggestion
40
+ * that errors is a second dead end on top of the first (see suggested-commands-exist.test.ts). Same
41
+ * wording as the empty-suite verdict, which is the same situation reached from `reticle verify`.
42
+ */
43
+ export declare const NO_FLOWS_NOTE = "no flows recorded \u2014 nothing was checked. Record one with reticle_record { action: \"start\" }, then reticle_flow_save.";
44
+ /** The gate's flow-coverage line. `pct` and `outcome` are mutually exclusive by construction. */
45
+ export interface FlowCoverageReport {
46
+ covered: number;
47
+ total: number;
48
+ pct?: number;
49
+ outcome?: typeof NO_FLOWS;
50
+ note?: string;
51
+ }
52
+ /**
53
+ * Report flow coverage so that an EMPTY suite cannot be read as a passing one.
54
+ *
55
+ * Same rule `buildSuiteVerdict` already applies to `reticle verify`, where zero flows reports
56
+ * `unverifiable` rather than "all 0 flows pass": a green that cannot go red is not a pass. Callers
57
+ * must treat a present `outcome` as not-passing.
58
+ */
59
+ export declare function flowCoverageReport(flows: CoverageDimension): FlowCoverageReport;
27
60
  export declare function computeCoverage(declared: DeclaredSurface, exercised: DeclaredSurface): Coverage;
28
61
  export {};
@@ -4,11 +4,36 @@
4
4
  * (contract testids/signals/flows) actually touched by passing verification, so "everything" is honest
5
5
  * about what everything means. Pure set math; the deviation report + gate carry the number.
6
6
  */
7
+ /** The gate's outcome when the project has recorded no flows at all — not a pass, and not a failure. */
8
+ export const NO_FLOWS = 'no_flows';
9
+ /**
10
+ * What to do about it, since the empty state is otherwise indistinguishable from success.
11
+ *
12
+ * Names the TOOLS, not a `reticle record` CLI verb: this CLI does not dispatch one, and a suggestion
13
+ * that errors is a second dead end on top of the first (see suggested-commands-exist.test.ts). Same
14
+ * wording as the empty-suite verdict, which is the same situation reached from `reticle verify`.
15
+ */
16
+ export const NO_FLOWS_NOTE = 'no flows recorded — nothing was checked. Record one with reticle_record { action: "start" }, then reticle_flow_save.';
17
+ /**
18
+ * Report flow coverage so that an EMPTY suite cannot be read as a passing one.
19
+ *
20
+ * Same rule `buildSuiteVerdict` already applies to `reticle verify`, where zero flows reports
21
+ * `unverifiable` rather than "all 0 flows pass": a green that cannot go red is not a pass. Callers
22
+ * must treat a present `outcome` as not-passing.
23
+ */
24
+ export function flowCoverageReport(flows) {
25
+ const { covered, total } = flows;
26
+ if (0 === total)
27
+ return { covered, total, outcome: NO_FLOWS, note: NO_FLOWS_NOTE };
28
+ return { covered, total, ...(flows.pct === undefined ? {} : { pct: flows.pct }) };
29
+ }
7
30
  function dimension(declared, exercised) {
8
31
  const uncovered = declared.filter((d) => !exercised.has(d));
9
32
  const covered = declared.length - uncovered.length;
10
- const pct = 0 === declared.length ? 100 : Math.round((covered / declared.length) * 100);
11
- return { total: declared.length, covered, pct, uncovered };
33
+ const base = { total: declared.length, covered, uncovered };
34
+ if (0 === declared.length)
35
+ return base;
36
+ return { ...base, pct: Math.round((covered / declared.length) * 100) };
12
37
  }
13
38
  export function computeCoverage(declared, exercised) {
14
39
  const testids = dimension(declared.testids, new Set(exercised.testids));
@@ -16,6 +41,7 @@ export function computeCoverage(declared, exercised) {
16
41
  const flows = dimension(declared.flows, new Set(exercised.flows));
17
42
  const total = testids.total + signals.total + flows.total;
18
43
  const covered = testids.covered + signals.covered + flows.covered;
19
- const overallPct = 0 === total ? 100 : Math.round((covered / total) * 100);
20
- return { testids, signals, flows, overallPct };
44
+ if (0 === total)
45
+ return { testids, signals, flows };
46
+ return { testids, signals, flows, overallPct: Math.round((covered / total) * 100) };
21
47
  }
@@ -1,5 +1,20 @@
1
1
  import { type FlowFile, type FlowReplayResult, type ReplayDecision, type SuiteVerdict } from '@reticlehq/core';
2
2
  export declare function buildDecision(result: FlowReplayResult, flow?: FlowFile): ReplayDecision;
3
+ /**
4
+ * Aggregate per-flow replays into one suite verdict — the autonomous loop's consolidated answer
5
+ * after a build: did anything break, and what's the prioritized fix list. Pure: pass the already
6
+ * decision-annotated replay results + their flows. Passing flows are counted; only failures carry
7
+ * detail (token-cheap), each with the actionable decision so the agent fixes without re-querying.
8
+ */
9
+ /**
10
+ * Why a green replay still verified nothing, or undefined when it genuinely did.
11
+ *
12
+ * A flow with no steps, or one that asserts no observable consequence, replays green no matter what
13
+ * the app does — the grader already knows this at save time and says so. Counting it as `passed`
14
+ * turned that warning into "all 1 flow pass", which is a false green in the exact feature sold as
15
+ * the regression suite. Classified only when the flow file is available; never guessed.
16
+ */
17
+ export declare function unverifiableReason(flow: FlowFile | undefined): string | undefined;
3
18
  export declare function buildSuiteVerdict(runs: ReadonlyArray<{
4
19
  replay: FlowReplayResult;
5
20
  flow?: FlowFile;
@@ -100,7 +100,7 @@ function suiteVerdictOf(status) {
100
100
  * turned that warning into "all 1 flow pass", which is a false green in the exact feature sold as
101
101
  * the regression suite. Classified only when the flow file is available; never guessed.
102
102
  */
103
- function unverifiableReason(flow) {
103
+ export function unverifiableReason(flow) {
104
104
  if (flow === undefined)
105
105
  return undefined;
106
106
  if (0 === flow.steps.length) {
@@ -2,7 +2,7 @@ import { EventType, FLOW_SIGNAL_TIMEOUT_MS, FlowErrorCode, RecordedFlowSchema, R
2
2
  import { asString } from '../tools/tools-helpers.js';
3
3
  import { replayFlow } from './flow-replay.js';
4
4
  import { assertSuccess, dynamicTestids, successLabel, SUCCESS_STEP_TOOL } from './flow-success.js';
5
- import { buildDecision } from './decision.js';
5
+ import { buildDecision, unverifiableReason } from './decision.js';
6
6
  import { waitForPredicate } from '../events/predicate.js';
7
7
  import { computeSegments } from '../journal/rollups.js';
8
8
  import { AssertionTiersStore } from './assertion-tiers-store.js';
@@ -174,6 +174,13 @@ export async function replayNamedFlow(deps, args) {
174
174
  return errored;
175
175
  }
176
176
  const result = { name, status, steps };
177
+ // A green that cannot go red is not a pass. `reticle_flow_verify` already refuses to count these,
178
+ // via this same function -- a single-flow caller saw a bare `ok` and had no way to learn the flow
179
+ // asserts nothing. Derived from the same helper on purpose: two copies of this judgement would
180
+ // drift, and the sibling tools would then disagree about the same flow.
181
+ const cannotFail = status === ReplayStatus.OK ? unverifiableReason(loaded.value) : undefined;
182
+ if (cannotFail !== undefined)
183
+ result.unverifiable = { reason: cannotFail };
177
184
  if (status !== ReplayStatus.OK)
178
185
  result.decision = buildDecision(result, loaded.value);
179
186
  applyStartPathHint(result, startPathHint);
@@ -255,7 +255,9 @@ export const FLOW_TOOLS = [
255
255
  'stale ref. On an anchor MISS returns legible DRIFT { step, anchor, drift:{ reasonKind, reason, ' +
256
256
  'nearest } } (the closest surviving testid) and stops — the "whose fault is it" contract. ' +
257
257
  'Returns { name, status: ok|drift|error, steps:[...] }; missing/malformed files and action ' +
258
- 'failures are status:error with a structured code (distinct from contract-changed drift).',
258
+ 'failures are status:error with a structured code (distinct from contract-changed drift). ' +
259
+ 'An ok replay of a flow that asserts no consequence also carries unverifiable:{reason} — it ' +
260
+ 'would read ok even if the feature were broken, so do NOT treat that ok as proof.',
259
261
  inputSchema: {
260
262
  flow: z
261
263
  .string()
@@ -285,6 +287,12 @@ export const FLOW_TOOLS = [
285
287
  .optional()
286
288
  .describe('Push-default deviation report over the replay segments: ranked deviations vs the learned envelope + a nominal count, or a fall-back note below 3 runs.'),
287
289
  error: z.object({ code: z.string(), message: z.string() }).optional(),
290
+ // Declared here or a validating profile strips it -- the same way `name` was stripped once.
291
+ // A field the handler sets and the schema omits arrives as nothing, silently.
292
+ unverifiable: z
293
+ .object({ reason: z.string() })
294
+ .optional()
295
+ .describe('Set on an ok replay that cannot fail: the flow asserts no observable consequence, so it passes even when the feature is broken. Same reason reticle_flow_verify reports.'),
288
296
  decision: z
289
297
  .object({
290
298
  verdict: z.string(),
@@ -247,6 +247,9 @@ export async function replayProgram(session, program, confirmDangerous = false)
247
247
  session.beginAction(ReticleTool.REPLAY, { steps: liveSteps.length });
248
248
  let r;
249
249
  try {
250
+ // DIVERGENCE: replay sends one batched ACT_SEQUENCE command to the browser;
251
+ // live (tools/act-tools.ts) sends N individual ACT commands for per-step timeout +
252
+ // progress. A bug in either is invisible from the other.
250
253
  r = await session.command(ReticleCommand.ACT_SEQUENCE, { steps: liveSteps });
251
254
  }
252
255
  finally {
@@ -10,5 +10,8 @@ import { type ReticleEvent } from '@reticlehq/core';
10
10
  * optimistically rendered "dispatched", the page settled, every channel agreed — and the server
11
11
  * REVERTED it to `held` 1.2 s later. The verdict was not wrong about what it observed. It was early,
12
12
  * and nothing in the response said so except the status code nobody was reading.
13
+ *
14
+ * The DEV TOOLCHAIN's own traffic is excluded, on the same rule every other check applies: a verdict
15
+ * about the app must not be decided by a request the app did not make.
13
16
  */
14
17
  export declare function hasAcceptedWrite(events: readonly ReticleEvent[]): boolean;
@@ -1,5 +1,5 @@
1
- import { EventType, HTTP_ACCEPTED } from '@reticlehq/core';
2
- import { asNumber } from '../tools/tools-helpers.js';
1
+ import { EventType, HTTP_ACCEPTED, isDevToolingUrl } from '@reticlehq/core';
2
+ import { asNumber, asString } from '../tools/tools-helpers.js';
3
3
  /**
4
4
  * Did a write in this window answer `202 Accepted`?
5
5
  *
@@ -11,7 +11,12 @@ import { asNumber } from '../tools/tools-helpers.js';
11
11
  * optimistically rendered "dispatched", the page settled, every channel agreed — and the server
12
12
  * REVERTED it to `held` 1.2 s later. The verdict was not wrong about what it observed. It was early,
13
13
  * and nothing in the response said so except the status code nobody was reading.
14
+ *
15
+ * The DEV TOOLCHAIN's own traffic is excluded, on the same rule every other check applies: a verdict
16
+ * about the app must not be decided by a request the app did not make.
14
17
  */
15
18
  export function hasAcceptedWrite(events) {
16
- return events.some((e) => e.type === EventType.NET_REQUEST && asNumber(e.data['status']) === HTTP_ACCEPTED);
19
+ return events.some((e) => e.type === EventType.NET_REQUEST &&
20
+ asNumber(e.data['status']) === HTTP_ACCEPTED &&
21
+ !isDevToolingUrl(asString(e.data['url'])));
17
22
  }
@@ -17,8 +17,23 @@
17
17
  import { PredicateKind } from '@reticlehq/core';
18
18
  export function readsDomState(predicate) {
19
19
  switch (predicate.kind) {
20
+ // ROUTE is here, and it did not used to be. It was purely event-based — satisfiable only by a
21
+ // navigation inside the act's window, and therefore unable to be answered by the past — so it
22
+ // sat with the floored kinds below.
23
+ //
24
+ // It gained a fallback to the CURRENT route for the case where the window holds no route change,
25
+ // which is what makes "did the session survive a reload?" answerable at all. Without a
26
+ // before-check that fallback is a guaranteed green: the app is already on /login, the click's
27
+ // handler is broken, nothing navigates, and an assertion that the action reached /login passes
28
+ // on the strength of it having been there all along. A bare `{ kind: 'route' }` becomes
29
+ // unconditionally true, because there is always a current route.
30
+ //
31
+ // One extra query on the act path buys `already_true`, reported as UNKNOWN with the reason
32
+ // rather than as a pass. The fallback removed a false red; this is what stops it becoming a
33
+ // false green, and that is the trade this codebase never makes.
20
34
  case PredicateKind.ELEMENT:
21
35
  case PredicateKind.TEXT:
36
+ case PredicateKind.ROUTE:
22
37
  return true;
23
38
  case PredicateKind.ALL_OF:
24
39
  case PredicateKind.ANY_OF:
@@ -40,6 +40,10 @@ const LABEL = {
40
40
  // nothing here was missed, there was simply never a verdict to observe, and no amount of extra
41
41
  // instrumentation would produce one.
42
42
  [BlindSpotKind.VERDICTLESS_SEND]: (n) => `${String(n)} one-way IPC send${1 === n ? '' : 's'} dispatched with NO verdict — the renderer never learns whether the main process handled ${1 === n ? 'it' : 'them'}, so this cannot be confirmed from the page`,
43
+ // Not a tally — a fact about the app's wiring, phrased as what the EMPTY state channel means. An
44
+ // agent reading `stateDiffs: []` otherwise reads "the app changed no state", which is a claim
45
+ // nothing here can support. Names the one-line fix, because the reader is the one who can apply it.
46
+ [BlindSpotKind.UNWATCHED_STATE]: () => 'no subscribable store is registered, so NO state change is observed — an empty stateDiffs here means unwatched, not unchanged (register the store itself in your reticle-dev module: registerStore(name, store), not a getter)',
43
47
  [BlindSpotKind.WRAPPED_NETWORK]: () => 'fetch was already wrapped before Reticle, so recorded requests may differ from what was sent',
44
48
  // A fact about the app's wiring, not a tally. Says what the empty network view MEANS, because an
45
49
  // empty one here is indistinguishable from "this app made no backend calls" — and that reading is
@@ -33,6 +33,15 @@ interface HonestyInputs {
33
33
  * knows whether a blind-spot note came from the browser transport or from a boundary in the page.
34
34
  */
35
35
  losses?: readonly CaptureLoss[];
36
+ /**
37
+ * Did the page go quiet inside the action's window? Omitted when nothing measured it.
38
+ *
39
+ * Settlement stopped being a veto over a declared consequence that held (see `verified.ts`), and a
40
+ * fact that no longer decides the verdict must not therefore disappear: a page that never goes
41
+ * idle is worth knowing about, and a caller who cares can still gate on it. Reported here rather
42
+ * than beside `verified`, where the answer belongs to the rule and not to its inputs.
43
+ */
44
+ settled?: boolean;
36
45
  }
37
46
  export interface HonestyBlock {
38
47
  grade: HonestyGrade;
@@ -52,6 +61,8 @@ export interface HonestyBlock {
52
61
  issues: string[];
53
62
  losses?: CaptureLoss[];
54
63
  };
64
+ /** Whether the page went quiet in this window — present only when it was measured. */
65
+ settled?: boolean;
55
66
  }
56
67
  export declare function buildHonestyBlock(inputs: HonestyInputs): HonestyBlock;
57
68
  interface HonestyBar {
@@ -46,6 +46,7 @@ export function buildHonestyBlock(inputs) {
46
46
  ? {}
47
47
  : { envelope: { samples, sufficient: samples >= MIN_ENVELOPE_SAMPLES } }),
48
48
  coverage: { ...(pct === undefined ? {} : { pct }), partial },
49
+ ...(inputs.settled === undefined ? {} : { settled: inputs.settled }),
49
50
  integrity: {
50
51
  clean: 0 === issues.length,
51
52
  issues,
@@ -8,12 +8,14 @@
8
8
  * every other field in the record agrees is a clean 200.
9
9
  *
10
10
  * So an omission is declared rather than left to inference, with the one line that fixes it. Present
11
- * only when a body-bearing call was returned AND none of them carried a body, so an app that already
12
- * captures bodies pays nothing and the field's PRESENCE is the warning.
11
+ * only when a body-bearing call was returned AND no body — request or response — was recorded
12
+ * anywhere in the result, so an app that already captures bodies pays nothing and the field's
13
+ * PRESENCE is the warning.
13
14
  */
14
15
  export declare function bodiesNotCaptured(calls: {
15
16
  method?: string;
16
17
  requestBody?: string;
18
+ responseBody?: string;
17
19
  }[]): {
18
20
  bodiesNotCaptured?: string;
19
21
  };
@@ -10,13 +10,20 @@ const BODY_BEARING_METHODS = new Set(['POST', 'PUT', 'PATCH']);
10
10
  * every other field in the record agrees is a clean 200.
11
11
  *
12
12
  * So an omission is declared rather than left to inference, with the one line that fixes it. Present
13
- * only when a body-bearing call was returned AND none of them carried a body, so an app that already
14
- * captures bodies pays nothing and the field's PRESENCE is the warning.
13
+ * only when a body-bearing call was returned AND no body — request or response — was recorded
14
+ * anywhere in the result, so an app that already captures bodies pays nothing and the field's
15
+ * PRESENCE is the warning.
15
16
  */
16
17
  export function bodiesNotCaptured(calls) {
17
18
  const bodyBearing = calls.filter((c) => BODY_BEARING_METHODS.has((c.method ?? '').toUpperCase()));
18
19
  if (0 === bodyBearing.length)
19
20
  return {};
21
+ // A recorded response body anywhere in the result proves capture is ON. An absent request body
22
+ // then means this payload was not stringified (a multipart upload, a body the SDK skipped), not
23
+ // that recording is off — and firing the note would contradict the body sitting in the same
24
+ // result, exactly on the question the note exists to answer. (#394)
25
+ if (calls.some((c) => c.responseBody !== undefined))
26
+ return {};
20
27
  if (bodyBearing.some((c) => c.requestBody !== undefined))
21
28
  return {};
22
29
  return {
@@ -1,2 +1,10 @@
1
1
  import { type ReticleEvent } from '@reticlehq/core';
2
+ /**
3
+ * The writes whose outcome went unread, as "METHOD url".
4
+ *
5
+ * Named rather than counted, because a caveat an agent cannot locate is a caveat it learns to skip.
6
+ * The sentence used to say "a write returned 2xx with a response body that was never recorded" and
7
+ * name no write, which is unactionable on any page that makes more than one call.
8
+ */
9
+ export declare function unreadWriteLabels(events: readonly ReticleEvent[]): string[];
2
10
  export declare function hasUnreadWriteOutcome(events: readonly ReticleEvent[]): boolean;