@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
package/dist/cli.js CHANGED
@@ -1,8 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { pathToFileURL } from 'node:url';
3
+ import { openFailureNote } from './cli/open-note.js';
3
4
  import { realpathSync } from 'node:fs';
4
5
  import { join } from 'node:path';
5
6
  import { stateDirProblem } from './daemon/state-dir.js';
7
+ import { statusNextAction } from './cli/status-next-action.js';
8
+ import { hasConnectedBefore } from './session/connection-memory.js';
6
9
  import { reticleStateHome } from './daemon/daemon.js';
7
10
  import { handleWatch, handleCapsules, handleGate, loadNamedFlows, resolveChangedFiles, } from './cli/cli-flow-commands.js';
8
11
  import { RETICLE_DEFAULT_PORT, ReticleDir, ReticleEnv } from '@reticlehq/core';
@@ -11,7 +14,7 @@ import { createNodeFileSystem } from './project/fs-port.js';
11
14
  import { affectedSavedFlows } from './flows/flow-sources.js';
12
15
  import { availableUpdate } from './update/update-nudge.js';
13
16
  import { handleUpdate, handleRollback } from './cli/cli-update-commands.js';
14
- import { start, startDaemon } from './index.js';
17
+ import { startDaemon } from './index.js';
15
18
  import { isCloudCommand, runCloudCommand } from './cli/cloud-cli.js';
16
19
  import { SERVER_VERSION } from './version/server-version.js';
17
20
  import { log } from './log.js';
@@ -22,7 +25,8 @@ import { installDaemonResilience, installProxyResilience, recordExitReason, Daem
22
25
  import { IdleShutdown, resolveIdleShutdownMs, resolveIdleCheckMs } from './daemon/idle-shutdown.js';
23
26
  import { DaemonHeartbeat, resolveHeartbeatMs } from './daemon/heartbeat.js';
24
27
  import { everServedToolCall } from './daemon/daemon-usefulness.js';
25
- import { fetchStatus, summarizeStatus, warnOnDaemonSkew, decideOpen, drivePortConflict, openInBrowser, openCommand, } from './cli/cli-launch.js';
28
+ import { fetchStatus, summarizeStatus, warnOnDaemonSkew, decideOpen, openInBrowser, openCommand, } from './cli/cli-launch.js';
29
+ import { handleDrive } from './cli/drive-command.js';
26
30
  import { handleVerify } from './cli/cli-verify.js';
27
31
  import { runKill } from './cli/cli-kill.js';
28
32
  import { summarizeHunt } from './hunt/hunt-report.js';
@@ -220,17 +224,34 @@ async function handleRestart(port, force) {
220
224
  // same lie `serve` already stopped telling.
221
225
  await serveWithHonestExit({ port, headless: true, http: false });
222
226
  }
227
+ /** `{ nextAction }` when there is one, `{}` when a session is connected — so the success case is silent. */
228
+ function withNextAction(facts) {
229
+ const next = statusNextAction(facts);
230
+ return next === undefined ? {} : { nextAction: next };
231
+ }
223
232
  function handleStatus(port) {
224
233
  const pid = readPid(port);
234
+ // Durable, so it survives the daemon idling out — which is the state `status` is most often run in.
235
+ const projectId = readProjectId(process.cwd());
236
+ const previouslyConnected = hasConnectedBefore(reticleStateHome(), port, projectId);
237
+ // Whether `init` has run HERE. Registering the MCP server does not wire the app, and more than one
238
+ // path does the first without the second — so this is the commonest reason `status` has nothing to
239
+ // report, and it was not among the facts this command could state.
240
+ const initialized = projectId !== undefined;
225
241
  if (null === pid || !isAlive(pid)) {
226
242
  // `running: false` on its own has been reported about a port that was demonstrably occupied,
227
243
  // because the pid file is not the port. Ask the port before answering.
228
244
  void probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus }).then((presence) => {
245
+ const running = presenceIsUsable(presence);
229
246
  log('reticle_status', {
230
247
  port,
231
- running: presenceIsUsable(presence),
248
+ running,
232
249
  presence,
233
250
  ...(presence === PortPresence.FOREIGN ? { reason: describePresence(presence, port) } : {}),
251
+ // `init` promises this command says why the app has not connected. Without it the answer was
252
+ // `running: false` and nothing else, which reads as "Reticle is broken" for what is usually
253
+ // just a daemon that has not been asked to do anything yet.
254
+ ...withNextAction({ running, sessionCount: 0, previouslyConnected, initialized }),
234
255
  });
235
256
  });
236
257
  return;
@@ -243,10 +264,23 @@ function handleStatus(port) {
243
264
  const update = availableUpdate();
244
265
  const nudge = update === undefined ? {} : { updateAvailable: update };
245
266
  if (payload === undefined) {
246
- log('reticle_status', { port, running: true, pid, ...nudge });
267
+ log('reticle_status', {
268
+ port,
269
+ running: true,
270
+ pid,
271
+ ...nudge,
272
+ ...withNextAction({ running: true, sessionCount: 0, previouslyConnected, initialized }),
273
+ });
247
274
  return;
248
275
  }
249
- log('reticle_status', { port, running: true, pid, ...summarizeStatus(payload), ...nudge });
276
+ const summary = summarizeStatus(payload);
277
+ // Only when the daemon did NOT already explain itself. It has the whole diagnosis in-process and
278
+ // puts it on the wire as `why`; printing a second, thinner opinion beside it risks two confident
279
+ // answers pointing different ways, which is worse than one.
280
+ const next = summary.why === undefined
281
+ ? withNextAction({ running: true, ...summary, previouslyConnected, initialized })
282
+ : {};
283
+ log('reticle_status', { port, running: true, pid, ...summary, ...next, ...nudge });
250
284
  });
251
285
  }
252
286
  /** Print the running package version (resolved once in server-version.ts). */
@@ -318,13 +352,20 @@ async function handleHunt(dir) {
318
352
  }
319
353
  /** Ensure a daemon is reachable on `port` (probe the real port; spawn + wait only if nothing's there). */
320
354
  function ensureDaemon(port) {
321
- return probeDaemon(port).then(async (listening) => {
355
+ return probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus }).then(async (presence) => {
322
356
  // Attaching to whatever already owns the port is the whole point of a daemon — but it means an
323
357
  // upgrade does NOT take effect until that daemon dies, and nothing used to say so. Say it here,
324
358
  // where both versions are in hand, and keep attaching: killing another agent's daemon on a
325
359
  // version bump is worse than a loud warning.
326
- if (listening)
360
+ if (presenceIsUsable(presence))
327
361
  return warnOnDaemonSkew(port);
362
+ // Not free, not a daemon: a stranger holds the port. Spawning here is guaranteed to fail —
363
+ // the child cannot bind — and `waitForDaemon` would then report READY anyway, because its
364
+ // probe is the same bare TCP connect and the stranger accepts. That is the "serve reports
365
+ // success for a daemon that never bound" shape. Refuse instead, with the sentence `doctor`
366
+ // says.
367
+ if (PortPresence.FREE !== presence)
368
+ throw new Error(describePresence(presence, port));
328
369
  const scriptPath = process.argv[1];
329
370
  if (scriptPath === undefined)
330
371
  throw new Error('cannot locate the reticle daemon script');
@@ -410,6 +451,7 @@ function handleOpen(requestedPort, url) {
410
451
  const connected = await waitForNewSession(port, sessions.length);
411
452
  log('reticle_open', {
412
453
  port,
454
+ ...(port === requestedPort ? {} : { requestedPort }),
413
455
  opened: decision.url,
414
456
  connected,
415
457
  ...(connected
@@ -422,14 +464,7 @@ function handleOpen(requestedPort, url) {
422
464
  // about a browser THIS command never uses, so a missing Chromium reads as the
423
465
  // explanation for a session that is missing for an unrelated reason. Both misdirects
424
466
  // were reported from the field, each costing several calls of app-side wiring hunt.
425
- note: `the URL was handed to the system default browser (this command does not use ` +
426
- `Reticle's own Chromium, so a chromium warning from \`reticle doctor\` is unrelated ` +
427
- `to this). No Reticle session appeared. By far the likeliest cause is that the app ` +
428
- `carries no Reticle SDK, or dials a port other than ${String(port)} — run ` +
429
- `\`reticle init\` in the app's directory and restart its dev server. If the app IS ` +
430
- `wired, give the page a moment and check the browser console: the SDK announces its ` +
431
- `own connect failures there, including the one it refuses to make from a ` +
432
- `non-localhost host without allowNonLocalhost.`,
467
+ note: openFailureNote(port, requestedPort),
433
468
  }),
434
469
  });
435
470
  })
@@ -478,6 +513,12 @@ function handleDaemonInner(parsed) {
478
513
  // which is after everything below has run. Without it the exit line says `code: 0` and a
479
514
  // reader cannot tell a tidy stop from the bridge disappearing — see #123.
480
515
  recordExitReason(reason);
516
+ // Say so on the wire before anything closes. The proxy on the other end sees a clean stream
517
+ // end whether we retired on schedule or died under it, and it has no other way to tell —
518
+ // which is how a designed shutdown came to dominate the metric that means "the agent lost
519
+ // its tools". Told, the proxy classifies its own drop honestly. First, because the send has
520
+ // to reach a socket that is still open.
521
+ server.announceShutdown?.();
481
522
  // Awaited before the close/exit chain: `process.exit(0)` kills an in-flight POST, and this is
482
523
  // the one event carrying the whole session. A failed send resolves anyway (emit swallows its
483
524
  // own errors), so this can delay the exit by at most the send timeout, never prevent it.
@@ -572,8 +613,15 @@ function handleMcp(opts) {
572
613
  * disappeared mid-session with nothing said. Respawning here makes the reconnect self-healing.
573
614
  */
574
615
  const ensure = async () => {
575
- if (await probeDaemon(port))
616
+ // The same question every other surface asks. It used to be a bare TCP connect, so a stranger on
617
+ // the bridge port answered "a daemon is here" — the proxy connected, the stream ended, and each
618
+ // client request woke into the identical non-answer. Rejecting here is what puts the proxy
619
+ // dormant with a reason, instead of pretending the wake succeeded.
620
+ const presence = await probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus });
621
+ if (presenceIsUsable(presence))
576
622
  return;
623
+ if (PortPresence.FREE !== presence)
624
+ throw new Error(describePresence(presence, port));
577
625
  const scriptPath = process.argv[1];
578
626
  if (scriptPath === undefined) {
579
627
  log('reticle_mcp_no_script', {});
@@ -640,59 +688,6 @@ function handleMcp(opts) {
640
688
  });
641
689
  });
642
690
  }
643
- function handleLegacyDrive(parsed) {
644
- void driveWithHonestConflict(parsed);
645
- }
646
- /**
647
- * Refuse the port BEFORE binding it, and name what is holding it.
648
- *
649
- * `drive` went straight to `start`, which binds. The listen error surfaces asynchronously on the
650
- * server object long after `start` has resolved, so the `.catch` on that promise could never see it
651
- * and the process died with the raw `node:net` EADDRINUSE stack — in the one situation this command
652
- * is most often run in, since `reticle_sessions` recommends `reticle drive` for a throttled tab and
653
- * a throttled tab nearly always coexists with the daemon holding the port.
654
- */
655
- async function driveWithHonestConflict(parsed) {
656
- const presence = await probePresence(parsed.port, { tcpOpen: probeDaemon, status: fetchStatus });
657
- const conflict = drivePortConflict(presence, parsed.port, { ourPid: readPid(parsed.port) });
658
- if (conflict !== undefined) {
659
- log('reticle_drive_port_conflict', { port: parsed.port, presence, reason: conflict });
660
- process.stderr.write(`${conflict}\n`);
661
- process.exit(1);
662
- return;
663
- }
664
- // The probe cannot close the race: something can take the port between here and the bind, and the
665
- // bind reports it on the server, outside every promise this function holds. Catching it at the
666
- // process is the only place that sees it at all — and only EADDRINUSE, so a genuine crash still
667
- // crashes rather than being dressed up as a port conflict. Who won the race is not knowable from
668
- // in here, so the sentence used is the one that is true of any holder.
669
- process.on('uncaughtException', (error) => {
670
- const code = error?.code;
671
- if ('EADDRINUSE' !== code)
672
- throw error;
673
- const reason = drivePortConflict(PortPresence.FOREIGN, parsed.port);
674
- log('reticle_drive_port_conflict', {
675
- port: parsed.port,
676
- presence: PortPresence.FOREIGN,
677
- reason,
678
- });
679
- process.stderr.write(`${String(reason)}\n`);
680
- process.exit(1);
681
- });
682
- const options = {
683
- port: parsed.port,
684
- driveUrl: parsed.driveUrl,
685
- headless: parsed.headless,
686
- };
687
- try {
688
- await start(options);
689
- log('reticle_started', { port: parsed.port });
690
- }
691
- catch (error) {
692
- log('reticle_start_failed', { error: error instanceof Error ? error.message : String(error) });
693
- process.exit(1);
694
- }
695
- }
696
691
  function main() {
697
692
  // Before anything reads process.env — notably the telemetry gate and the bridge's security
698
693
  // options — fold in a project-local `.env`. Values already in the environment always win.
@@ -782,7 +777,7 @@ function main() {
782
777
  handleOpen(parsed.port, parsed.url);
783
778
  break;
784
779
  case 'drive':
785
- handleLegacyDrive(parsed);
780
+ handleDrive(parsed);
786
781
  break;
787
782
  case 'verify':
788
783
  handleVerify(parsed);
@@ -1,4 +1,4 @@
1
- import { CrawlAnomalyKind, ContradictionKind, type CommandResult, type ReticleEvent } from '@reticlehq/core';
1
+ import { type CommandResult, type ReticleEvent } from '@reticlehq/core';
2
2
  /** The slice of Session the crawler needs — so tests inject a fake without a live browser. */
3
3
  export interface CrawlSession {
4
4
  command(name: string, args?: Record<string, unknown>): Promise<CommandResult>;
@@ -21,8 +21,13 @@ export interface CrawlAnomaly {
21
21
  * disagreeing about the same click). The second kind is why an autonomous crawl is worth more
22
22
  * than a human clicking the same buttons: a person sees only the screen, so a UI that advanced
23
23
  * while its write failed looks like success to them and always will.
24
+ *
25
+ * `CrawlAnomalyKind` and `ContradictionKind` are what THIS package puts here, and both are still
26
+ * enforced at the emit sites below. The type is a plain string because a rule registered by a
27
+ * consumer contributes kinds this package has never heard of, and a closed union here would mean
28
+ * a consumer's private vocabulary had to ship in the free product to be reportable.
24
29
  */
25
- kind: CrawlAnomalyKind | ContradictionKind;
30
+ kind: string;
26
31
  /** The control that triggered it. */
27
32
  ref: string;
28
33
  desc: string;
@@ -1,5 +1,5 @@
1
1
  import { span } from '../trace.js';
2
- import { ActionType, CRAWL_DEFAULTS, CrawlAnomalyKind, ContradictionKind, DANGEROUS_ACTION_CONFIRM_ARG, EventType, ReticleCommand, } from '@reticlehq/core';
2
+ import { ActionType, CRAWL_DEFAULTS, CrawlAnomalyKind, DANGEROUS_ACTION_CONFIRM_ARG, EventType, ReticleCommand, } from '@reticlehq/core';
3
3
  import { crawlEmptyNote } from './crawl-empty.js';
4
4
  import { parseInteractive, asRecord, asNumber, asString, sourceOf, } from '../tools/tools-helpers.js';
5
5
  import { ReticleTool } from '../tools/tool-names.js';
@@ -86,6 +86,37 @@ async function countRevealed(session, opts, before) {
86
86
  const known = new Set(before.map((i) => (i.ref !== '' ? i.ref : i.desc)));
87
87
  return parseInteractive(tree).filter((i) => !known.has(i.ref !== '' ? i.ref : i.desc)).length;
88
88
  }
89
+ /**
90
+ * The confirmation half of the dead-control rule. Kept separate so the rule above reads as one
91
+ * question, and so the second click gets its own window: reusing the first would let the first
92
+ * click's own (absent) activity decide the second, which is not a second sample at all.
93
+ *
94
+ * `true` means "silent both times" — the only state the anomaly is reported in. Anything that goes
95
+ * wrong here returns `false`: a confirmation that cannot run is not evidence, and the direction that
96
+ * costs a missed finding is better than the one that invents one.
97
+ */
98
+ async function stillSilent(session, item, settleMs, opts, sleep) {
99
+ const since = session.elapsed();
100
+ session.beginAction?.(ReticleTool.CRAWL, { ref: item.ref, action: ActionType.CLICK });
101
+ try {
102
+ const again = await session.command(ReticleCommand.ACT, {
103
+ ref: item.ref,
104
+ action: ActionType.CLICK,
105
+ args: true === opts.confirmDangerous ? { [DANGEROUS_ACTION_CONFIRM_ARG]: true } : {},
106
+ });
107
+ await sleep(settleMs);
108
+ // A control that has GONE (the first click removed it) is not a dead control — it did something.
109
+ if (!again.ok || false === asRecord(again.result)['dispatched'])
110
+ return false;
111
+ return !session.eventsSince(since).some(isActivity);
112
+ }
113
+ catch {
114
+ return false;
115
+ }
116
+ finally {
117
+ session.finishAction?.();
118
+ }
119
+ }
89
120
  export async function crawl(session, opts, sleep) {
90
121
  const maxSteps = opts.maxSteps ?? CRAWL_DEFAULTS.MAX_STEPS;
91
122
  const settleMs = opts.settleMs ?? CRAWL_DEFAULTS.SETTLE_MS;
@@ -194,14 +225,26 @@ export async function crawl(session, opts, sleep) {
194
225
  0 === errs.length &&
195
226
  !events.some(isActivity) &&
196
227
  !legitimatelyInert(item.desc)) {
197
- counts.deadControls += 1;
198
- anomalies.push({
199
- kind: CrawlAnomalyKind.DEAD_CONTROL,
200
- ref: item.ref,
201
- desc: item.desc,
202
- detail: 'clicked but the app did nothing (no DOM/network/route/signal change)',
203
- ...source,
204
- });
228
+ // CONFIRM IT. One silent sample is a sample, not a fact, and this is the strongest claim the
229
+ // crawler makes — "your button does nothing" is the finding a reader acts on immediately.
230
+ //
231
+ // Measured on a fixture whose FIXED twin has a working primary CTA: the crawl reported it dead
232
+ // on 2 of 3 runs, always a control the drive had already clicked successfully moments earlier.
233
+ // A false positive in this tier is the most expensive kind there is — it is the tier a verdict
234
+ // is built from, and the twin column is the whole claim this product makes.
235
+ //
236
+ // A second click costs one round trip on the rare control that looked dead, and nothing at all
237
+ // on every control that did not. A genuinely dead control is silent twice; the flake is not.
238
+ if (await stillSilent(session, item, settleMs, opts, sleep)) {
239
+ counts.deadControls += 1;
240
+ anomalies.push({
241
+ kind: CrawlAnomalyKind.DEAD_CONTROL,
242
+ ref: item.ref,
243
+ desc: item.desc,
244
+ detail: 'clicked TWICE and the app did nothing either time (no DOM/network/route/signal change)',
245
+ ...source,
246
+ });
247
+ }
205
248
  }
206
249
  }
207
250
  // One more look, AFTER the clicks. The enumeration above happened before any of them, so anything
@@ -3,9 +3,9 @@
3
3
  * ee feature follows: call assertEnterprise...) first, then do the privileged work. The body here is a
4
4
  * stub — the real audit sink is the hosted control plane. It exists now to prove the gate.
5
5
  */
6
- import { assertEnterprise } from '../license/license.js';
6
+ import { assertEnterprise, EnterpriseFeature } from '../license/license.js';
7
7
  /** Record an audit event — gated. Throws EnterpriseLicenseError in production without a valid license. */
8
8
  export function recordAuditEvent(event, ctx) {
9
- assertEnterprise('audit-log', ctx);
9
+ assertEnterprise(EnterpriseFeature.AUDIT_LOG, ctx);
10
10
  return event; // stub: a real implementation persists to the enterprise audit store
11
11
  }
@@ -1,4 +1,4 @@
1
1
  import { type ReticleEvent } from '@reticlehq/core';
2
- import type { Contradiction } from './contradictions.js';
2
+ import type { OwnContradiction } from './contradictions.js';
3
3
  /** Contradictions for 2xx responses whose bodies report failure. One per offending call. */
4
- export declare function findBodyFailures(events: readonly ReticleEvent[]): Contradiction[];
4
+ export declare function findBodyFailures(events: readonly ReticleEvent[]): OwnContradiction[];
@@ -0,0 +1,45 @@
1
+ import type { ReticleEvent } from '@reticlehq/core';
2
+ import type { Contradiction, ContradictionOptions } from './contradictions.js';
3
+ /**
4
+ * Rules a CONSUMER adds to the contradiction pass, without editing the ones this package ships.
5
+ *
6
+ * Every rule in `contradictions.ts` is a pure fold over a recording — events in, findings out, no
7
+ * session, no clock, no IO. A service embedding this engine wants rules of exactly that shape for
8
+ * itself, and the only thing standing between it and one was that `findContradictions` folds over a
9
+ * list written into its own body. Appending to that list means editing this package, which means a
10
+ * fork, which means the consumer's rules and this package's rules become one tree to merge forever.
11
+ *
12
+ * A registry rather than a parameter on `findContradictions`, deliberately. There are six call sites
13
+ * and a consumer reaches none of them: the tools call the pass internally, and a tool handler is
14
+ * handed a deps bag, not options. Threading a parameter would mean touching every path a verdict can
15
+ * take — and would still miss the one inside `crawl`. Registration happens once, at boot, by whoever
16
+ * composed the process.
17
+ *
18
+ * This is not a public plugin API and should not be documented as one. It has a single intended
19
+ * consumer, which is a service we own, and its shape is free to change in lockstep with it.
20
+ */
21
+ /**
22
+ * One consumer rule.
23
+ *
24
+ * Receives the app's OWN events — dev-tooling traffic is already removed, exactly as it is for the
25
+ * shipped rules. A second answer to "which events count" would be a second product.
26
+ */
27
+ export type ContradictionFold = (events: readonly ReticleEvent[], options: ContradictionOptions) => readonly Contradiction[];
28
+ /**
29
+ * Add a rule to every verdict this engine produces. Returns the undo.
30
+ *
31
+ * The undo is not a convenience: without it a registry is process-global state that tests cannot
32
+ * clean up, so each one starts depending on the order it ran in.
33
+ */
34
+ export declare function registerContradictionFold(fold: ContradictionFold): () => void;
35
+ /** What is registered right now. Exported so a guard can assert the registry is empty by default. */
36
+ export declare function registeredContradictionFolds(): readonly ContradictionFold[];
37
+ /**
38
+ * Run every registered rule, containing anything one of them throws.
39
+ *
40
+ * A registered fold is somebody else's code running inside every verdict path this package has. If a
41
+ * throw could propagate, one defect in a consumer's rule would take down `assert`, `act_and_wait`,
42
+ * `observe` and `crawl` at once — turning a missing finding into a dead engine. Contained and logged:
43
+ * the rules that did run still report, and the failure is visible rather than swallowed.
44
+ */
45
+ export declare function runRegisteredFolds(events: readonly ReticleEvent[], options: ContradictionOptions): Contradiction[];
@@ -0,0 +1,42 @@
1
+ import { log } from '../log.js';
2
+ const FOLDS = [];
3
+ /**
4
+ * Add a rule to every verdict this engine produces. Returns the undo.
5
+ *
6
+ * The undo is not a convenience: without it a registry is process-global state that tests cannot
7
+ * clean up, so each one starts depending on the order it ran in.
8
+ */
9
+ export function registerContradictionFold(fold) {
10
+ FOLDS.push(fold);
11
+ return () => {
12
+ const at = FOLDS.indexOf(fold);
13
+ if (at !== -1)
14
+ FOLDS.splice(at, 1);
15
+ };
16
+ }
17
+ /** What is registered right now. Exported so a guard can assert the registry is empty by default. */
18
+ export function registeredContradictionFolds() {
19
+ return [...FOLDS];
20
+ }
21
+ /**
22
+ * Run every registered rule, containing anything one of them throws.
23
+ *
24
+ * A registered fold is somebody else's code running inside every verdict path this package has. If a
25
+ * throw could propagate, one defect in a consumer's rule would take down `assert`, `act_and_wait`,
26
+ * `observe` and `crawl` at once — turning a missing finding into a dead engine. Contained and logged:
27
+ * the rules that did run still report, and the failure is visible rather than swallowed.
28
+ */
29
+ export function runRegisteredFolds(events, options) {
30
+ const found = [];
31
+ for (const fold of FOLDS) {
32
+ try {
33
+ found.push(...fold(events, options));
34
+ }
35
+ catch (error) {
36
+ log('contradiction_fold_failed', {
37
+ error: error instanceof Error ? error.message : String(error),
38
+ });
39
+ }
40
+ }
41
+ return found;
42
+ }
@@ -1,4 +1,5 @@
1
1
  import { ContradictionKind, type ReticleEvent } from '@reticlehq/core';
2
+ import { type DeclaredNetFailure } from './declared.js';
2
3
  /**
3
4
  * The contradiction hunter.
4
5
  *
@@ -18,7 +19,16 @@ import { ContradictionKind, type ReticleEvent } from '@reticlehq/core';
18
19
  * Pure: a window of events in, findings out. No session, no IO, no clock.
19
20
  */
20
21
  export interface Contradiction {
21
- kind: ContradictionKind;
22
+ /**
23
+ * Typed `string`, not `ContradictionKind`, because the vocabulary is open at the EDGE.
24
+ *
25
+ * `ContradictionKind` enumerates what THIS package's rules emit, and every one of them is still
26
+ * checked against it — see `OwnContradiction`, which is what the folds below build. A rule
27
+ * registered by a consumer emits kinds this package has never heard of and must not have to add
28
+ * them here to be reportable: a shared enum is exactly how a consumer's private vocabulary ends up
29
+ * shipped in the free product by accident.
30
+ */
31
+ kind: string;
22
32
  /** What one channel asserted — the optimistic half. */
23
33
  claim: string;
24
34
  /** What the other channel asserted — the half that contradicts it. */
@@ -26,6 +36,15 @@ export interface Contradiction {
26
36
  /** Concrete evidence, so the agent can go straight to the call or the control. */
27
37
  detail: string;
28
38
  }
39
+ /**
40
+ * A contradiction emitted by one of THIS package's rules — the kind is closed.
41
+ *
42
+ * Widening `Contradiction.kind` to `string` for the consumer seam would otherwise have made every
43
+ * emit site below accept a typo'd literal. This keeps them checked without closing the edge.
44
+ */
45
+ export type OwnContradiction = Contradiction & {
46
+ kind: ContradictionKind;
47
+ };
29
48
  export interface ContradictionOptions {
30
49
  /** The action that opened this window, when one did. Enables the no-effect check. */
31
50
  action?: string | undefined;
@@ -63,5 +82,36 @@ export interface ContradictionOptions {
63
82
  * test, which is weaker but never wrong in the direction of a false accusation.
64
83
  */
65
84
  mutatedWithin?: number | undefined;
85
+ /**
86
+ * Requests the CALLER declared would fail, read off the oracle it wrote before acting.
87
+ *
88
+ * `ui-advanced-request-failed` cannot tell "the app swallowed the error and carried on" from "the
89
+ * app rendered the error, which is the behaviour under test" — both are a moved DOM beside a
90
+ * failed request. `failureAcknowledged` recovers the first from the app's own state, and an app
91
+ * that renders its error straight into the DOM without touching a store defeats it.
92
+ *
93
+ * The declaration is the missing evidence, and it costs no new API: an agent verifying an error
94
+ * path already writes `{ net, POST, /api/login, status: 500 }` into the predicate. Measured in the
95
+ * field: every branch of a login error path (500, 401, 503) passed every declared clause and every
96
+ * run still returned `verified: "no" / contradicted`, so error handling — empty states, offline
97
+ * banners, 4xx/5xx messaging, lockouts — was the code least able to reach a green verdict and the
98
+ * most worth verifying.
99
+ *
100
+ * Scoped as narrowly as it can be: it suppresses ONLY the heuristic rule that cannot see the
101
+ * difference. A success signal fired over the declared failure still contradicts, and a server
102
+ * fault blamed on the user is still misattributed — see the negative controls in
103
+ * `contradictions.declared.test.ts`.
104
+ */
105
+ expectedFailures?: readonly DeclaredNetFailure[] | undefined;
106
+ /**
107
+ * The caller declared an on-screen consequence and it HELD in this window.
108
+ *
109
+ * `route-rendered-nothing` infers a blank destination from the absence of DOM events, and a
110
+ * verdict that names it beside an element match — heading found, with its source file and line —
111
+ * is a clause its own evidence disproves. Positive evidence outranks the absence it is inferred
112
+ * from. Undefined/false leaves the rule exactly as it was, which is the case that catches a route
113
+ * with no view.
114
+ */
115
+ renderProved?: boolean | undefined;
66
116
  }
67
117
  export declare function findContradictions(allEvents: readonly ReticleEvent[], options?: ContradictionOptions): Contradiction[];
@@ -4,6 +4,8 @@ import { findBodyFailures } from './body-failures.js';
4
4
  import { findEchoMismatches } from './echo-mismatch.js';
5
5
  import { findUnitMismatches } from './unit-mismatch.js';
6
6
  import { asNumber, asString } from '../tools/tools-helpers.js';
7
+ import { matchesDeclaredFailure } from './declared.js';
8
+ import { runRegisteredFolds } from './contradiction-folds.js';
7
9
  function netCall(e) {
8
10
  const status = asNumber(e.data['status']);
9
11
  return {
@@ -171,7 +173,7 @@ export function findContradictions(allEvents, options = {}) {
171
173
  // Needs body capture; silent without it, which is why the assert path also declares when bodies
172
174
  // were never recorded rather than letting an unread payload read as an empty one.
173
175
  found.push(...findBodyFailures(events));
174
- found.push(...findEchoMismatches(events));
176
+ found.push(...findEchoMismatches(events, options.actionSince));
175
177
  // ── A money value written back at the wrong SCALE ───────────────────────────────────────────
176
178
  found.push(...findUnitMismatches(events, options.prior ?? []));
177
179
  // ── The action landed on something that does not react ──────────────────────────────────────
@@ -192,6 +194,9 @@ export function findContradictions(allEvents, options = {}) {
192
194
  ];
193
195
  }
194
196
  const failed = settled.filter((c) => false === c.ok);
197
+ // The failures NOBODY declared — see ContradictionOptions.expectedFailures. Only the heuristic
198
+ // "the UI moved while a request failed" rule reads this; the sharp rules still read `failed`.
199
+ const unexpected = failed.filter((c) => !matchesDeclaredFailure(c, options.expectedFailures ?? []));
195
200
  const advanced = uiAdvanced(events);
196
201
  const signals = events
197
202
  .filter((e) => e.type === EventType.SIGNAL)
@@ -215,7 +220,7 @@ export function findContradictions(allEvents, options = {}) {
215
220
  e.type === EventType.DOM_REMOVED ||
216
221
  e.type === EventType.DOM_TEXT);
217
222
  const fetched = events.some((e) => e.type === EventType.NET_REQUEST || e.type === EventType.NET_PENDING);
218
- if (routed && !rendered && !fetched) {
223
+ if (routed && !rendered && !fetched && true !== options.renderProved) {
219
224
  found.push({
220
225
  kind: ContradictionKind.ROUTE_RENDERED_NOTHING,
221
226
  claim: 'the app navigated to a new route',
@@ -251,7 +256,19 @@ export function findContradictions(allEvents, options = {}) {
251
256
  // claimed success" about an app that plainly reported a failure is true in outline and wrong in
252
257
  // its reasoning, which is how a checker stops being believed.
253
258
  const successSignals = signals.filter((name) => !ACKNOWLEDGED.test(name));
254
- if (failed.length > 0 && successSignals.length > 0) {
259
+ // An app that RETRACTED has not claimed success, whenever it fired the optimistic signal.
260
+ //
261
+ // The weaker UI rule below already consulted this and the sharper signal rule did not, so an app
262
+ // that announced "ack:requested", met a 500, and then correctly emitted "ack:failed" and rolled the
263
+ // row back was reported as explicitly asserting success — the strongest accusation this file makes,
264
+ // against code doing exactly the right thing. On a fixture suite the scenario's FIXED twin produced
265
+ // the same finding as the build that swallowed the failure, which makes the finding worthless on
266
+ // the one measurement that scores precision.
267
+ //
268
+ // Ordering cannot decide this: an optimistic UI legitimately fires its success signal BEFORE the
269
+ // response, so "the claim must follow the failure" would miss the real defect. What separates them
270
+ // is not when the app spoke, it is whether it took it back.
271
+ if (failed.length > 0 && successSignals.length > 0 && !failureAcknowledged(events)) {
255
272
  found.push({
256
273
  kind: ContradictionKind.SIGNAL_CONTRADICTED,
257
274
  claim: `the app fired ${successSignals.map((s) => `"${s}"`).join(', ')}`,
@@ -259,12 +276,12 @@ export function findContradictions(allEvents, options = {}) {
259
276
  detail: failed.map(describe).join('; '),
260
277
  });
261
278
  }
262
- else if (failed.length > 0 && advanced && !misattributed && !failureAcknowledged(events)) {
279
+ else if (unexpected.length > 0 && advanced && !misattributed && !failureAcknowledged(events)) {
263
280
  found.push({
264
281
  kind: ContradictionKind.UI_ADVANCED_REQUEST_FAILED,
265
282
  claim: 'the UI moved forward (DOM/store/route changed)',
266
- counter: `${String(failed.length)} request(s) in the same window failed`,
267
- detail: failed.map(describe).join('; '),
283
+ counter: `${String(unexpected.length)} request(s) in the same window failed`,
284
+ detail: unexpected.map(describe).join('; '),
268
285
  });
269
286
  }
270
287
  // ── A write succeeded and nothing on the client moved ───────────────────────────────────────
@@ -337,5 +354,7 @@ export function findContradictions(allEvents, options = {}) {
337
354
  });
338
355
  }
339
356
  }
340
- return found;
357
+ // Consumer rules run LAST and over the same app-only window, so a service embedding this engine
358
+ // adds to the verdict rather than forking the file that produces it.
359
+ return [...found, ...runRegisteredFolds(events, options)];
341
360
  }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * What the caller DECLARED before acting — the oracle, read back.
3
+ *
4
+ * `act_and_wait` asks the agent to name the expected consequence BEFORE the action, and that
5
+ * declaration was then thrown away: everything downstream judged the window as if nobody had said
6
+ * what they were expecting. Two measured consequences, one bug:
7
+ *
8
+ * - An error path can be declared exactly — `{ net, POST, /api/login, status: 500 }` — and the
9
+ * contradiction hunter still reported `ui-advanced-request-failed`, because a failed request plus
10
+ * a moved DOM is all it can see. The failure was EXPECTED and said so in the predicate, so a
11
+ * verdict of "channels disagree" is a statement about a disagreement nobody had.
12
+ * - A destination whose content was asserted and FOUND was still reported as
13
+ * `route-rendered-nothing`, a clause the element evidence beside it disproves.
14
+ *
15
+ * Deliberately conservative about what counts as declared. Only what the caller REQUIRED is read:
16
+ * the top level and `allOf` chains. An `anyOf` branch may never have held, and a `not` declares the
17
+ * opposite of a consequence — honouring either would suppress a real finding on the strength of
18
+ * something that never happened. Pure: a predicate in, a description out.
19
+ */
20
+ import type { Predicate } from './predicate-eval.js';
21
+ /** A failing call the caller named in advance — matched against the window's real calls. */
22
+ export interface DeclaredNetFailure {
23
+ method?: string;
24
+ urlContains?: string;
25
+ status?: number;
26
+ }
27
+ export interface DeclaredExpectations {
28
+ /** Requests the caller declared would FAIL, so failing is the expected outcome, not a disagreement. */
29
+ netFailures: readonly DeclaredNetFailure[];
30
+ /** The caller required something to be ON SCREEN — an element or text, present rather than absent. */
31
+ rendersContent: boolean;
32
+ }
33
+ export declare function declaredExpectations(predicate: Predicate | undefined): DeclaredExpectations;
34
+ /**
35
+ * Does a call the window recorded match a failure the caller declared?
36
+ *
37
+ * Every field the caller named must agree — a declaration about `POST /api/login → 500` says nothing
38
+ * about `POST /api/orders`, and treating it as a blanket amnesty for failed requests would remove
39
+ * the check rather than inform it. Fields the caller left out are not constraints.
40
+ */
41
+ export declare function matchesDeclaredFailure(call: {
42
+ method: string;
43
+ url: string;
44
+ status: number | undefined;
45
+ }, declared: readonly DeclaredNetFailure[]): boolean;