@reticlehq/server 2.5.0 → 2.7.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 (182) hide show
  1. package/SKILL.md +226 -26
  2. package/dist/bridge/bridge.d.ts +18 -1
  3. package/dist/bridge/bridge.js +59 -6
  4. package/dist/bridge/bridge.test-harness.d.ts +8 -0
  5. package/dist/bridge/bridge.test-harness.js +9 -1
  6. package/dist/cli/chromium-hint.d.ts +40 -0
  7. package/dist/cli/chromium-hint.js +75 -0
  8. package/dist/cli/cli-doctor.d.ts +0 -5
  9. package/dist/cli/cli-doctor.js +74 -14
  10. package/dist/cli/cli-launch.d.ts +1 -0
  11. package/dist/cli/cli-launch.js +4 -1
  12. package/dist/cli/cli-parse-feedback.d.ts +31 -0
  13. package/dist/cli/cli-parse-feedback.js +56 -0
  14. package/dist/cli/cli-parse.d.ts +5 -9
  15. package/dist/cli/cli-parse.js +7 -23
  16. package/dist/cli/cli-port.js +20 -1
  17. package/dist/cli/doctor-daemon-line.d.ts +37 -0
  18. package/dist/cli/doctor-daemon-line.js +44 -0
  19. package/dist/cli/port-holder.d.ts +43 -0
  20. package/dist/cli/port-holder.js +99 -0
  21. package/dist/cli.js +170 -19
  22. package/dist/daemon/daemon-resilience.d.ts +24 -0
  23. package/dist/daemon/daemon-resilience.js +105 -17
  24. package/dist/daemon/daemon.js +26 -1
  25. package/dist/daemon/heartbeat.d.ts +115 -0
  26. package/dist/daemon/heartbeat.js +141 -0
  27. package/dist/daemon/idle-shutdown.d.ts +13 -5
  28. package/dist/daemon/idle-shutdown.js +14 -6
  29. package/dist/daemon/loopback-alias.d.ts +32 -0
  30. package/dist/daemon/loopback-alias.js +80 -0
  31. package/dist/daemon/port-presence.d.ts +76 -0
  32. package/dist/daemon/port-presence.js +93 -0
  33. package/dist/daemon/state-dir.d.ts +7 -0
  34. package/dist/daemon/state-dir.js +59 -0
  35. package/dist/daemon/unclean-predecessor.d.ts +23 -0
  36. package/dist/daemon/unclean-predecessor.js +27 -0
  37. package/dist/events/predicate-eval.d.ts +17 -3
  38. package/dist/events/predicate-eval.js +183 -96
  39. package/dist/events/predicate-parse.d.ts +23 -0
  40. package/dist/events/predicate-parse.js +68 -0
  41. package/dist/events/predicate.d.ts +5 -0
  42. package/dist/events/predicate.js +17 -3
  43. package/dist/events/ring-buffer.d.ts +8 -0
  44. package/dist/events/ring-buffer.js +35 -17
  45. package/dist/flows/flow-replay.d.ts +1 -1
  46. package/dist/flows/flow-replay.js +19 -2
  47. package/dist/flows/flow-step-runners.js +2 -3
  48. package/dist/flows/flow-tools.js +10 -14
  49. package/dist/flows/role-drift-reason.d.ts +20 -0
  50. package/dist/flows/role-drift-reason.js +39 -0
  51. package/dist/flows/suite-flakes.d.ts +33 -0
  52. package/dist/flows/suite-flakes.js +32 -0
  53. package/dist/honesty/blind-spots.d.ts +19 -0
  54. package/dist/honesty/blind-spots.js +33 -0
  55. package/dist/honesty/honesty.d.ts +11 -0
  56. package/dist/honesty/honesty.js +9 -1
  57. package/dist/honesty/verified.d.ts +10 -1
  58. package/dist/honesty/verified.js +42 -3
  59. package/dist/http-server.js +20 -1
  60. package/dist/index.d.ts +1 -0
  61. package/dist/index.js +36 -3
  62. package/dist/init/agent-rules.d.ts +18 -0
  63. package/dist/init/agent-rules.js +28 -5
  64. package/dist/init/cra.d.ts +13 -0
  65. package/dist/init/cra.js +16 -0
  66. package/dist/init/detect.d.ts +14 -0
  67. package/dist/init/detect.js +23 -0
  68. package/dist/init/mcp-clients.d.ts +81 -0
  69. package/dist/init/mcp-clients.js +248 -0
  70. package/dist/init/node-io.js +8 -2
  71. package/dist/init/non-js-project.d.ts +27 -0
  72. package/dist/init/non-js-project.js +60 -0
  73. package/dist/init/plan-framework.d.ts +9 -0
  74. package/dist/init/plan-framework.js +55 -3
  75. package/dist/init/plan.d.ts +14 -0
  76. package/dist/init/plan.js +93 -12
  77. package/dist/init/run.d.ts +18 -1
  78. package/dist/init/run.js +169 -23
  79. package/dist/init/snippets.d.ts +20 -1
  80. package/dist/init/snippets.js +71 -6
  81. package/dist/init/windows-quote.d.ts +38 -0
  82. package/dist/init/windows-quote.js +70 -0
  83. package/dist/mcp/mcp-outage.d.ts +1 -0
  84. package/dist/mcp/mcp-outage.js +8 -1
  85. package/dist/mcp/mcp-proxy.d.ts +11 -1
  86. package/dist/mcp/mcp-proxy.js +39 -9
  87. package/dist/mcp/mcp.js +64 -2
  88. package/dist/mcp/proxy-lifecycle.d.ts +19 -0
  89. package/dist/mcp/proxy-lifecycle.js +21 -0
  90. package/dist/pool/browser-pool.d.ts +9 -0
  91. package/dist/pool/browser-pool.js +38 -9
  92. package/dist/pool/playwright-launcher.js +10 -2
  93. package/dist/session/attachment-history.d.ts +46 -0
  94. package/dist/session/attachment-history.js +65 -0
  95. package/dist/session/live-control-tools.js +39 -2
  96. package/dist/session/live-control.d.ts +7 -9
  97. package/dist/session/live-control.js +34 -1
  98. package/dist/session/no-session-diagnosis.d.ts +11 -4
  99. package/dist/session/no-session-diagnosis.js +95 -14
  100. package/dist/session/no-session-watch.d.ts +3 -1
  101. package/dist/session/no-session-watch.js +10 -2
  102. package/dist/session/pending-commands.js +1 -0
  103. package/dist/session/review-store.d.ts +19 -0
  104. package/dist/session/review-store.js +34 -5
  105. package/dist/session/session-info.d.ts +17 -1
  106. package/dist/session/session-info.js +5 -1
  107. package/dist/session/session-manager.d.ts +12 -0
  108. package/dist/session/session-manager.js +83 -3
  109. package/dist/session/session.d.ts +9 -0
  110. package/dist/session/session.js +20 -0
  111. package/dist/status-payload.d.ts +13 -1
  112. package/dist/status-payload.js +6 -1
  113. package/dist/telemetry/app-instrumented.d.ts +37 -0
  114. package/dist/telemetry/app-instrumented.js +54 -0
  115. package/dist/telemetry/crash-cause.d.ts +47 -0
  116. package/dist/telemetry/crash-cause.js +104 -0
  117. package/dist/telemetry/daemon-telemetry.d.ts +25 -7
  118. package/dist/telemetry/daemon-telemetry.js +32 -13
  119. package/dist/telemetry/error-class.d.ts +26 -0
  120. package/dist/telemetry/error-class.js +52 -0
  121. package/dist/telemetry/feedback-cli.d.ts +7 -2
  122. package/dist/telemetry/feedback-cli.js +49 -5
  123. package/dist/telemetry/feedback-context.d.ts +1 -6
  124. package/dist/telemetry/feedback-context.js +64 -4
  125. package/dist/telemetry/git-facts.d.ts +7 -0
  126. package/dist/telemetry/git-facts.js +4 -4
  127. package/dist/telemetry/project-profile.js +2 -1
  128. package/dist/telemetry/session-metrics.d.ts +45 -3
  129. package/dist/telemetry/session-metrics.js +247 -9
  130. package/dist/telemetry/telemetry.d.ts +21 -1
  131. package/dist/telemetry/telemetry.js +34 -9
  132. package/dist/telemetry/verification-of.js +26 -1
  133. package/dist/tools/act-tools.js +63 -14
  134. package/dist/tools/assert-verdict.d.ts +7 -1
  135. package/dist/tools/assert-verdict.js +22 -3
  136. package/dist/tools/browser-tools.js +8 -2
  137. package/dist/tools/contract-tools.d.ts +0 -5
  138. package/dist/tools/contract-tools.js +26 -0
  139. package/dist/tools/dynamic-tools.js +6 -1
  140. package/dist/tools/error-recovery.d.ts +14 -0
  141. package/dist/tools/error-recovery.js +89 -8
  142. package/dist/tools/feedback-invite.d.ts +48 -0
  143. package/dist/tools/feedback-invite.js +71 -0
  144. package/dist/tools/invoke-tool.d.ts +0 -6
  145. package/dist/tools/invoke-tool.js +76 -7
  146. package/dist/tools/navigate-arrival.d.ts +26 -0
  147. package/dist/tools/navigate-arrival.js +60 -0
  148. package/dist/tools/navigate-result.d.ts +15 -1
  149. package/dist/tools/navigate-result.js +17 -10
  150. package/dist/tools/observe-tools.js +22 -4
  151. package/dist/tools/real-input-attempt.js +10 -5
  152. package/dist/tools/tool-kit.d.ts +60 -5
  153. package/dist/tools/tool-kit.js +55 -24
  154. package/dist/tools/tool-surface.js +13 -0
  155. package/dist/tools/tools.js +22 -0
  156. package/dist/visual/capture-cleanup.d.ts +16 -0
  157. package/dist/visual/capture-cleanup.js +39 -0
  158. package/dist/visual/visual-tools.d.ts +18 -0
  159. package/dist/visual/visual-tools.js +24 -5
  160. package/docs/README.md +55 -0
  161. package/docs/agent-cheatsheet.md +15 -3
  162. package/docs/benchmarks.md +1 -1
  163. package/docs/fixtures-dispatch-receiver.yml +76 -0
  164. package/docs/fixtures.md +17 -0
  165. package/docs/gate-plan.md +98 -0
  166. package/docs/gates.md +117 -0
  167. package/docs/local-registry.md +1 -1
  168. package/docs/matrix/2.5.0/claude-code-machine.json +19 -0
  169. package/docs/matrix/2.5.0/codex-machine.json +20 -0
  170. package/docs/matrix/2.5.0/cursor-machine.json +22 -0
  171. package/docs/matrix/2.5.0/gemini-machine.json +22 -0
  172. package/docs/matrix/2.5.0/opencode-machine.json +22 -0
  173. package/docs/matrix/2.5.0/vscode-machine.json +22 -0
  174. package/docs/matrix/2.5.0/windsurf-machine.json +22 -0
  175. package/docs/matrix/MATRIX.md +26 -0
  176. package/docs/matrix/README.md +59 -0
  177. package/docs/mint.json +1 -1
  178. package/docs/system-map.md +152 -0
  179. package/docs/telemetry-contract.md +15 -3
  180. package/docs/telemetry.md +16 -1
  181. package/docs/token-efficiency.md +2 -2
  182. package/package.json +2 -2
@@ -1,19 +1,4 @@
1
- import { EventType, RING_BUFFER_DEFAULTS } from '@reticlehq/core';
2
- /**
3
- * The high-volume, low-signal floor. These fire continuously on any live page (count-up text, CSS
4
- * animation frames, React commits, health heartbeats, scroll) and are the ONLY thing that should be
5
- * sacrificed when the buffer is full. Everything else — a failed request, a console error, a domain
6
- * signal, a truncation/blind-spot marker — is scarce evidence a verdict depends on, and a churning
7
- * region must never be able to push it out of the window.
8
- */
9
- const CHURN_TYPES = new Set([
10
- EventType.DOM_TEXT,
11
- EventType.ANIM_START,
12
- EventType.ANIM_END,
13
- EventType.RENDER_COMMIT,
14
- EventType.PAGE_HEALTH,
15
- EventType.SCROLL_POSITION,
16
- ]);
1
+ import { CHURN_TYPES, RING_BUFFER_DEFAULTS } from '@reticlehq/core';
17
2
  /** How far forward to look for a churn event to sacrifice before falling back to plain FIFO. */
18
3
  const CHURN_SCAN_LIMIT = 256;
19
4
  /**
@@ -38,6 +23,16 @@ export class RingBuffer {
38
23
  #head = 0;
39
24
  #totalBytes = 0;
40
25
  #droppedCount = 0;
26
+ /**
27
+ * The timestamp of the newest NON-CHURN event this buffer has ever evicted, or `undefined` if it
28
+ * has never lost one. This — not the drop counter — is what impeaches an observation window.
29
+ *
30
+ * The counter cannot answer the question. It moves for age eviction (everything past 60s, on every
31
+ * push) and for churn eviction (the low-signal floor, sacrificed on purpose so scarce evidence
32
+ * survives), neither of which is evidence lost from the window an agent just observed. See
33
+ * `lostSince` and `ring-buffer-window-loss.test.ts`.
34
+ */
35
+ #lastScarceLossT;
41
36
  constructor(options = {}) {
42
37
  this.#maxEvents = options.maxEvents ?? RING_BUFFER_DEFAULTS.MAX_EVENTS;
43
38
  this.#maxAgeMs = options.maxAgeMs ?? RING_BUFFER_DEFAULTS.MAX_AGE_MS;
@@ -96,7 +91,10 @@ export class RingBuffer {
96
91
  }
97
92
  const victim = this.#findChurnAfterHead();
98
93
  if (-1 === victim) {
99
- // Buffer genuinely full of high-signal events — fall back to FIFO so it stays bounded.
94
+ // Buffer genuinely full of high-signal events — fall back to FIFO so it stays bounded. This
95
+ // is the one eviction path that destroys scarce evidence, and the only one that should ever
96
+ // make a verdict say it could not see.
97
+ this.#noteScarceLoss(this.#head);
100
98
  this.#totalBytes -= this.#eventBytes[this.#head] ?? 0;
101
99
  this.#head += 1;
102
100
  continue;
@@ -106,6 +104,7 @@ export class RingBuffer {
106
104
  this.#eventBytes.splice(victim, 1);
107
105
  }
108
106
  while (this.#liveCount() > 0 && (this.#events[this.#head]?.t ?? cutoff) < cutoff) {
107
+ this.#noteScarceLoss(this.#head);
109
108
  this.#totalBytes -= this.#eventBytes[this.#head] ?? 0;
110
109
  this.#head += 1;
111
110
  }
@@ -117,6 +116,25 @@ export class RingBuffer {
117
116
  this.#head = 0;
118
117
  }
119
118
  }
119
+ /** Record an eviction at `index` as scarce loss, unless it was the churn floor being sacrificed. */
120
+ #noteScarceLoss(index) {
121
+ const victim = this.#events[index];
122
+ if (victim === undefined || CHURN_TYPES.has(victim.type))
123
+ return;
124
+ if (this.#lastScarceLossT === undefined || victim.t > this.#lastScarceLossT) {
125
+ this.#lastScarceLossT = victim.t;
126
+ }
127
+ }
128
+ /**
129
+ * Did this buffer evict scarce evidence that belonged to a window opened at `cursor`?
130
+ *
131
+ * The one honest input to "was the capture clean". A `false` here means every non-churn event in
132
+ * the window is still held, whatever the drop counter says — and the drop counter says a great
133
+ * deal, because age eviction runs on every push.
134
+ */
135
+ lostSince(cursor) {
136
+ return this.#lastScarceLossT !== undefined && this.#lastScarceLossT >= cursor;
137
+ }
120
138
  /** First churn event after the head, or -1 if none within the bounded scan (keeps eviction cheap). */
121
139
  #findChurnAfterHead() {
122
140
  const end = Math.min(this.#events.length, this.#head + CHURN_SCAN_LIMIT);
@@ -49,7 +49,7 @@ export type Sleep = (ms: number) => Promise<void>;
49
49
  export declare function nearestIsAmbiguous(missing: string, present: string[]): boolean;
50
50
  /** Build the legible-drift record for a testid anchor that resolved to zero live elements. */
51
51
  export declare function testidDrift(value: string, hint: QueryEmptyHint | undefined): Drift;
52
- export declare function resolveQuery(session: FlowReplaySession, queryArgs: Record<string, unknown>, sleep: Sleep): Promise<{
52
+ export declare function resolveQuery(session: FlowReplaySession, queryArgs: Record<string, unknown>, sleep: Sleep, now?: () => number): Promise<{
53
53
  refs: string[];
54
54
  hint?: QueryEmptyHint;
55
55
  }>;
@@ -61,6 +61,22 @@ export function nearestTestid(missing, present) {
61
61
  */
62
62
  const ANCHOR_SETTLE_ATTEMPTS = 8;
63
63
  const ANCHOR_SETTLE_DELAY_MS = 150;
64
+ /**
65
+ * The budget that actually governs, in wall-clock milliseconds.
66
+ *
67
+ * Attempts were the bound, and on an event-chatty page that collapsed. `settleTick` ends on EITHER
68
+ * an event OR the tick, so a page emitting continuously (API calls, a large form render, CSS
69
+ * transition start/end pairs) burned all eight attempts in **224–758ms measured** — a budget
70
+ * documented as 1.2s, spent in a fifth of it, before a newly routed page had mounted its controls.
71
+ * Cross-route replays drifted `testid_not_found` at 278ms while the same flow passed on a quiet page.
72
+ *
73
+ * An event arriving is evidence the page is still working. It should EXTEND the wait, not spend it.
74
+ * So the deadline decides when to give up, and the attempt cap below survives only as a backstop
75
+ * against a pathological storm spinning this loop hot.
76
+ */
77
+ const ANCHOR_SETTLE_BUDGET_MS = ANCHOR_SETTLE_ATTEMPTS * ANCHOR_SETTLE_DELAY_MS;
78
+ /** Backstop only. Generous on purpose: the deadline is the real bound, this just prevents a spin. */
79
+ const ANCHOR_SETTLE_MAX_ATTEMPTS = 40;
64
80
  const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
65
81
  /** Extract the live element refs + the zero-match near-miss hint from a QUERY command result. */
66
82
  function readQuery(result) {
@@ -158,9 +174,10 @@ async function settleTick(session, sleep) {
158
174
  unsubscribe?.();
159
175
  }
160
176
  }
161
- export async function resolveQuery(session, queryArgs, sleep) {
177
+ export async function resolveQuery(session, queryArgs, sleep, now = Date.now) {
162
178
  let last = readQuery(await session.command(ReticleCommand.QUERY, queryArgs));
163
- for (let attempt = 1; 0 === last.refs.length && attempt < ANCHOR_SETTLE_ATTEMPTS; attempt += 1) {
179
+ const deadline = now() + ANCHOR_SETTLE_BUDGET_MS;
180
+ for (let attempt = 1; 0 === last.refs.length && now() < deadline && attempt < ANCHOR_SETTLE_MAX_ATTEMPTS; attempt += 1) {
164
181
  await settleTick(session, sleep);
165
182
  last = readQuery(await session.command(ReticleCommand.QUERY, queryArgs));
166
183
  }
@@ -11,6 +11,7 @@ import { ReticleTool } from '../tools/tool-names.js';
11
11
  import { replayActionArgs } from './replay.js';
12
12
  import { anchorLabel, componentLabel, componentQueryArgs, resolveQuery, testidDrift, } from './flow-replay.js';
13
13
  import { nearestRoleName } from './role-anchor-nearest.js';
14
+ import { roleDriftReason } from './role-drift-reason.js';
14
15
  /** Query args for a NAMED role anchor — the handle that identifies an instance, not a JSX site. */
15
16
  function roleQueryArgs(anchor) {
16
17
  return {
@@ -74,9 +75,7 @@ export async function runRoleStep(session, step, index, anchor, confirmDangerous
74
75
  ok: false,
75
76
  drift: {
76
77
  reasonKind: DriftReason.COMPONENT_NOT_FOUND,
77
- reason: null === nearest
78
- ? `role anchor ${label} not found, and no surviving ${anchor.role} has a similar name — anchor this step to a data-testid`
79
- : `role anchor ${label} not found; the closest surviving ${anchor.role} is "${nearest}" — confirm it is the same control before rebinding, or add a data-testid`,
78
+ reason: roleDriftReason(anchor.role, String(anchor.name), nearest),
80
79
  anchor: label,
81
80
  nearest,
82
81
  },
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  import { emptyFlowRefusal } from './empty-flow.js';
3
3
  import { aliasParam } from '../tools/alias-args.js';
4
4
  import { FlowErrorCode, RecordedSaveError, ReplayStatus, } from '@reticlehq/core';
5
- import { FlakeStore } from './flake-store.js';
5
+ import { recordSuiteFlakes } from './suite-flakes.js';
6
6
  import { ReticleTool } from '../tools/tool-names.js';
7
7
  import { asNumber, asString } from '../tools/tools-helpers.js';
8
8
  import { log } from '../log.js';
@@ -383,18 +383,15 @@ export const FLOW_TOOLS = [
383
383
  const flow = loaded !== null && loaded.ok ? loaded.value : undefined;
384
384
  return flow === undefined ? { replay } : { replay, flow };
385
385
  }));
386
- return buildSuiteVerdict(parallelRuns);
386
+ const flaky = await recordSuiteFlakes(deps.fs, deps.reticleRoot, parallelRuns);
387
+ const verdict = buildSuiteVerdict(parallelRuns);
388
+ return flaky.length > 0 ? { ...verdict, flaky: [...flaky] } : verdict;
387
389
  }
388
390
  // The flow FILE travels with each replay so the verdict can tell a green that verified
389
391
  // something from a green that could never have gone red. Without it the suite reported "all 1
390
392
  // flow pass" for a flow with no steps at all.
391
393
  const runs = [];
392
394
  const timed = [];
393
- // The flake ledger the CLI gate already keeps — see FlakeStore. It was only ever written by
394
- // `reticle flow` on the command line, so an AGENT running this tool a hundred times learned
395
- // nothing about which flows are intermittent. Same product, same ledger, two surfaces, and the
396
- // one an agent uses was the blind half.
397
- const flakes = new FlakeStore(deps.fs, deps.reticleRoot);
398
395
  // Sequential default: every flow replays against the same live session, so they must not overlap.
399
396
  for (const flowName of requested) {
400
397
  const start = deps.now();
@@ -404,19 +401,18 @@ export const FLOW_TOOLS = [
404
401
  runs.push(flow === undefined ? { replay } : { replay, flow });
405
402
  timed.push({ replay, durationMs: deps.now() - start });
406
403
  }
407
- // Accrue this run's outcomes, then report what the ledger knows. Best-effort on both halves: a
408
- // flake ledger is memory, not a gate, and it must never turn a working verify into an error.
409
- for (const { replay } of runs) {
410
- await flakes.record(replay.name, replay.status === ReplayStatus.OK).catch(() => undefined);
411
- }
412
- const flaky = await flakes.flakyFlows().catch(() => []);
404
+ // The flake ledger the CLI gate already keeps — see FlakeStore. It was only ever written by
405
+ // `reticle flow` on the command line, so an AGENT running this tool a hundred times learned
406
+ // nothing about which flows are intermittent. Same product, same ledger, two surfaces, and the
407
+ // one an agent uses was the blind half.
408
+ const flaky = await recordSuiteFlakes(deps.fs, deps.reticleRoot, runs);
413
409
  // Emit the consolidated run artifact (Runs tab) + best-effort cloud push. Never blocks the verdict.
414
410
  await persistAndSyncVerificationRun(deps, timed, projectId);
415
411
  const verdict = buildSuiteVerdict(runs);
416
412
  // A flow that has both passed and failed on UNCHANGED code is a different thing from a
417
413
  // regression, and an agent that cannot tell them apart either chases a ghost or ignores a real
418
414
  // break. Present only when the ledger has seen enough runs to say so.
419
- return flaky.length > 0 ? { ...verdict, flaky } : verdict;
415
+ return flaky.length > 0 ? { ...verdict, flaky: [...flaky] } : verdict;
420
416
  },
421
417
  },
422
418
  {
@@ -0,0 +1,20 @@
1
+ /**
2
+ * What to say when a role+name anchor no longer resolves.
3
+ *
4
+ * The old message could contradict itself in the one case that matters most:
5
+ *
6
+ * role anchor button "Add User" not found; the closest surviving button is "Add User"
7
+ *
8
+ * Read literally that says the control both is and is not there, and it cost a reporter real
9
+ * debugging time. What it means is more useful than a rename: the stored name and the live
10
+ * accessible name are the SAME STRING, so the name is not what changed — the query matched nothing
11
+ * for some other reason.
12
+ *
13
+ * In the reported case the recorded name came from a `placeholder` — `reticle_query` reports
14
+ * `name:"Search User"` for `<input type="search" placeholder="Search User">` with no label — and the
15
+ * replay resolver computes accessible names differently, so the round trip cannot close. That is a
16
+ * real bug in its own right (two accessible-name implementations disagreeing), and while it is open
17
+ * the message is the only thing the reader has, because `flow_heal` correctly refuses to rebind a
18
+ * role anchor on its own.
19
+ */
20
+ export declare function roleDriftReason(role: string, name: string, nearest: string | null): string;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * What to say when a role+name anchor no longer resolves.
3
+ *
4
+ * The old message could contradict itself in the one case that matters most:
5
+ *
6
+ * role anchor button "Add User" not found; the closest surviving button is "Add User"
7
+ *
8
+ * Read literally that says the control both is and is not there, and it cost a reporter real
9
+ * debugging time. What it means is more useful than a rename: the stored name and the live
10
+ * accessible name are the SAME STRING, so the name is not what changed — the query matched nothing
11
+ * for some other reason.
12
+ *
13
+ * In the reported case the recorded name came from a `placeholder` — `reticle_query` reports
14
+ * `name:"Search User"` for `<input type="search" placeholder="Search User">` with no label — and the
15
+ * replay resolver computes accessible names differently, so the round trip cannot close. That is a
16
+ * real bug in its own right (two accessible-name implementations disagreeing), and while it is open
17
+ * the message is the only thing the reader has, because `flow_heal` correctly refuses to rebind a
18
+ * role anchor on its own.
19
+ */
20
+ /** Same name to a reader: case and surrounding whitespace are not a rename. */
21
+ function sameName(a, b) {
22
+ return a.trim().toLowerCase() === b.trim().toLowerCase();
23
+ }
24
+ export function roleDriftReason(role, name, nearest) {
25
+ const label = `${role} "${name}"`;
26
+ if (null === nearest) {
27
+ return (`role anchor ${label} not found, and no surviving ${role} has a similar name — ` +
28
+ 'anchor this step to a data-testid');
29
+ }
30
+ if (sameName(name, nearest)) {
31
+ // The one case the old wording made unreadable. Say what is ruled OUT — that is the information.
32
+ return (`role anchor ${label} did not resolve, but a ${role} with an identical name is on the page — ` +
33
+ 'so this is NOT a rename and editing the label will not fix it. The recorded name and the ' +
34
+ 'name the resolver computes disagree, which happens when the recorded one came from a ' +
35
+ 'placeholder or title rather than a label or aria-label. Anchor this step to a data-testid.');
36
+ }
37
+ return (`role anchor ${label} not found; the closest surviving ${role} is "${nearest}" — ` +
38
+ 'confirm it is the same control before rebinding, or add a data-testid');
39
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Accrue a suite run's outcomes into the flake ledger, and report what the ledger knows.
3
+ *
4
+ * This existed twice — once in `flow_verify`'s sequential branch and once, after #240, in its
5
+ * parallel branch. That duplication is not incidental: the parallel branch shipped WITHOUT it, so an
6
+ * agent verifying in parallel built no flake evidence at all and never saw the quarantine set, and
7
+ * nothing went red because the other copy was still correct. One function both branches call is what
8
+ * makes that miss impossible on one side only.
9
+ *
10
+ * It is also what makes the behaviour testable. The handler itself needs a live browser session, so
11
+ * both branches were only ever pinned by tests that RE-IMPLEMENTED this loop — which passed with the
12
+ * shipped code reverted (measured on #240: 4/4 green with the fix absent). Extracted, the real thing
13
+ * runs in a unit test.
14
+ *
15
+ * Best-effort on both halves, deliberately: a flake ledger is memory, not a gate. A full disk must
16
+ * never turn a working verification into an error.
17
+ */
18
+ import { ReplayStatus } from '@reticlehq/core';
19
+ import type { FileSystemPort } from '../project/fs-port.js';
20
+ /** Just the shape this needs from a replay — so a test does not have to build a whole result. */
21
+ export interface FlakeOutcome {
22
+ readonly name: string;
23
+ readonly status: ReplayStatus;
24
+ }
25
+ /**
26
+ * Record every outcome, then ask which flows are intermittent.
27
+ *
28
+ * Anything that is not `OK` — `ERROR` or `DRIFT` — counts as a failure for the ledger. That is the
29
+ * rule both call sites already used, preserved rather than chosen here.
30
+ */
31
+ export declare function recordSuiteFlakes(fs: FileSystemPort, reticleRoot: string, runs: readonly {
32
+ readonly replay: FlakeOutcome;
33
+ }[]): Promise<readonly string[]>;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Accrue a suite run's outcomes into the flake ledger, and report what the ledger knows.
3
+ *
4
+ * This existed twice — once in `flow_verify`'s sequential branch and once, after #240, in its
5
+ * parallel branch. That duplication is not incidental: the parallel branch shipped WITHOUT it, so an
6
+ * agent verifying in parallel built no flake evidence at all and never saw the quarantine set, and
7
+ * nothing went red because the other copy was still correct. One function both branches call is what
8
+ * makes that miss impossible on one side only.
9
+ *
10
+ * It is also what makes the behaviour testable. The handler itself needs a live browser session, so
11
+ * both branches were only ever pinned by tests that RE-IMPLEMENTED this loop — which passed with the
12
+ * shipped code reverted (measured on #240: 4/4 green with the fix absent). Extracted, the real thing
13
+ * runs in a unit test.
14
+ *
15
+ * Best-effort on both halves, deliberately: a flake ledger is memory, not a gate. A full disk must
16
+ * never turn a working verification into an error.
17
+ */
18
+ import { ReplayStatus } from '@reticlehq/core';
19
+ import { FlakeStore } from './flake-store.js';
20
+ /**
21
+ * Record every outcome, then ask which flows are intermittent.
22
+ *
23
+ * Anything that is not `OK` — `ERROR` or `DRIFT` — counts as a failure for the ledger. That is the
24
+ * rule both call sites already used, preserved rather than chosen here.
25
+ */
26
+ export async function recordSuiteFlakes(fs, reticleRoot, runs) {
27
+ const flakes = new FlakeStore(fs, reticleRoot);
28
+ for (const { replay } of runs) {
29
+ await flakes.record(replay.name, ReplayStatus.OK === replay.status).catch(() => undefined);
30
+ }
31
+ return flakes.flakyFlows().catch(() => []);
32
+ }
@@ -65,3 +65,22 @@ export declare function blindSpotsFromEvents(events: readonly ReticleEvent[]): B
65
65
  * from what happened to be said recently.
66
66
  */
67
67
  export declare function blindSpotsFromState(state: Readonly<Record<string, number>>): BlindSpot[];
68
+ /**
69
+ * Events the BROWSER's own transport queue threw away inside this window, or 0.
70
+ *
71
+ * `RATE_LIMITED` — the BRIDGE sampling because events arrived faster than its per-second cap — is
72
+ * already the one blind spot that `impeachesCapture`, on the reasoning that a green over a window
73
+ * you did not fully see "would only describe what was observed". `TRANSPORT_OVERFLOW` is the exact
74
+ * same loss on the other side of the wire, and it was read in journal rollups and NOWHERE on the
75
+ * verdict path. So the identical condition downgraded a verdict from one side of the socket and was
76
+ * invisible from the other: a window that dropped 34 events graded `proved`, in a sentence whose own
77
+ * words were "over a clean capture".
78
+ *
79
+ * `TRUNCATED` is deliberately NOT counted. It names the channel it capped (a DOM-mutation flood on
80
+ * any busy page) and is routine churn; treating it as lost evidence would caveat every verdict on
81
+ * every real app, and a caveat that is always present is one nobody reads. TRANSPORT_OVERFLOW is the
82
+ * honest "arbitrary events are gone" marker — it cannot say which.
83
+ */
84
+ export declare function droppedByTransport(events: readonly ReticleEvent[]): number;
85
+ /** The impeaching note for a transport gap, or undefined when the window was intact. */
86
+ export declare function transportGapNote(events: readonly ReticleEvent[]): string | undefined;
@@ -109,3 +109,36 @@ export function blindSpotsFromEvents(events) {
109
109
  export function blindSpotsFromState(state) {
110
110
  return Object.entries(state).map(([kind, count]) => ({ kind: kind, count }));
111
111
  }
112
+ /**
113
+ * Events the BROWSER's own transport queue threw away inside this window, or 0.
114
+ *
115
+ * `RATE_LIMITED` — the BRIDGE sampling because events arrived faster than its per-second cap — is
116
+ * already the one blind spot that `impeachesCapture`, on the reasoning that a green over a window
117
+ * you did not fully see "would only describe what was observed". `TRANSPORT_OVERFLOW` is the exact
118
+ * same loss on the other side of the wire, and it was read in journal rollups and NOWHERE on the
119
+ * verdict path. So the identical condition downgraded a verdict from one side of the socket and was
120
+ * invisible from the other: a window that dropped 34 events graded `proved`, in a sentence whose own
121
+ * words were "over a clean capture".
122
+ *
123
+ * `TRUNCATED` is deliberately NOT counted. It names the channel it capped (a DOM-mutation flood on
124
+ * any busy page) and is routine churn; treating it as lost evidence would caveat every verdict on
125
+ * every real app, and a caveat that is always present is one nobody reads. TRANSPORT_OVERFLOW is the
126
+ * honest "arbitrary events are gone" marker — it cannot say which.
127
+ */
128
+ export function droppedByTransport(events) {
129
+ let dropped = 0;
130
+ for (const e of events) {
131
+ if (e.type !== EventType.TRANSPORT_OVERFLOW)
132
+ continue;
133
+ const n = e.data['dropped'];
134
+ dropped += 'number' === typeof n ? n : 0;
135
+ }
136
+ return dropped;
137
+ }
138
+ /** The impeaching note for a transport gap, or undefined when the window was intact. */
139
+ export function transportGapNote(events) {
140
+ const dropped = droppedByTransport(events);
141
+ return 0 === dropped
142
+ ? undefined
143
+ : `the browser dropped ${String(dropped)} event(s) in this window (transport queue overflow), so events that would have contradicted this may never have arrived`;
144
+ }
@@ -5,6 +5,7 @@
5
5
  * block. Harnesses gate on it (e.g. "grade ≥ net AND integrity clean"). This is pure composition +
6
6
  * assessment — all five components already exist individually; wiring it onto results is a later step.
7
7
  */
8
+ import type { CaptureLoss } from '@reticlehq/core';
8
9
  /** Assertion grade, strongest first — the tier the verdict actually proved. */
9
10
  export declare const HonestyGrade: {
10
11
  readonly SIGNAL: "signal";
@@ -23,6 +24,15 @@ interface HonestyInputs {
23
24
  coveragePartial?: boolean;
24
25
  truncated?: boolean;
25
26
  blindSpots?: readonly string[];
27
+ /**
28
+ * The machine-readable half of `issues` — WHICH kind of loss, from core's closed `CaptureLoss`.
29
+ *
30
+ * `issues` is prose built for an agent to read, and prose is not something a dashboard can group
31
+ * by: `unclean_capture` became a large share of all field verdicts with no way to ask which of its three
32
+ * causes produced them. Passed by the caller rather than derived here, because only the caller
33
+ * knows whether a blind-spot note came from the browser transport or from a boundary in the page.
34
+ */
35
+ losses?: readonly CaptureLoss[];
26
36
  }
27
37
  export interface HonestyBlock {
28
38
  grade: HonestyGrade;
@@ -40,6 +50,7 @@ export interface HonestyBlock {
40
50
  integrity: {
41
51
  clean: boolean;
42
52
  issues: string[];
53
+ losses?: CaptureLoss[];
43
54
  };
44
55
  }
45
56
  export declare function buildHonestyBlock(inputs: HonestyInputs): HonestyBlock;
@@ -46,7 +46,15 @@ export function buildHonestyBlock(inputs) {
46
46
  ? {}
47
47
  : { envelope: { samples, sufficient: samples >= MIN_ENVELOPE_SAMPLES } }),
48
48
  coverage: { ...(pct === undefined ? {} : { pct }), partial },
49
- integrity: { clean: 0 === issues.length, issues },
49
+ integrity: {
50
+ clean: 0 === issues.length,
51
+ issues,
52
+ // Only when something was actually lost. An empty array beside `clean: true` would be a field
53
+ // that is always present and always says nothing, which is how a signal gets tuned out.
54
+ ...(0 === issues.length || undefined === inputs.losses || 0 === inputs.losses.length
55
+ ? {}
56
+ : { losses: [...new Set(inputs.losses)] }),
57
+ },
50
58
  };
51
59
  }
52
60
  /** Whether a verdict's honesty block clears the harness's bar. Reasons list every failed criterion. */
@@ -14,6 +14,15 @@ import { type HonestyBlock } from './honesty.js';
14
14
  interface VerifiedInputs {
15
15
  /** Did the declared consequence hold? Undefined when the action declared none. */
16
16
  pass?: boolean;
17
+ /**
18
+ * The wait ended because the TAB went away, not because the app did anything.
19
+ *
20
+ * A structured flag rather than a match on `failureReason`, which is free prose everywhere else it
21
+ * is produced and describes what the APP did. A string test here would silently reclassify the
22
+ * next app-side reason that happened to mention a disconnect, which is the same
23
+ * confidently-wrong-by-accident this clause exists to remove.
24
+ */
25
+ observationLost?: boolean;
17
26
  honesty: HonestyBlock;
18
27
  /**
19
28
  * Set when the assertion could not be EVALUATED at all — an under-specified call, or nothing
@@ -47,7 +56,7 @@ interface VerifiedVerdict {
47
56
  verified: Verified;
48
57
  /**
49
58
  * WHICH clause below decided this, as a closed enum. `verified` has three values and this rule has
50
- * ten clauses, so the verdict alone collapses opposite facts — "Reticle caught a real bug", "the
59
+ * eleven clauses, so the verdict alone collapses opposite facts — "Reticle caught a real bug", "the
51
60
  * agent wrote a bad predicate" and "Reticle could not see" all arrived as one string. Named here,
52
61
  * beside the sentence, so the vocabulary cannot drift from the branches that produce it.
53
62
  */
@@ -1,4 +1,4 @@
1
- import { Verified, VerifiedReason } from '@reticlehq/core';
1
+ import { Verified, VerifiedReason, isAbsenceDerived } from '@reticlehq/core';
2
2
  import { HonestyGrade } from './honesty.js';
3
3
  export function decideVerified(inputs) {
4
4
  const { pass, honesty, contradictions = [], settled, outcomePending, outcomeUnread } = inputs;
@@ -12,6 +12,22 @@ export function decideVerified(inputs) {
12
12
  because: `the assertion could not be evaluated: ${inputs.inconclusive}`,
13
13
  };
14
14
  }
15
+ // ABOVE the failure clause, because a wait that ended when the tab vanished did not fail — it was
16
+ // never finished. `waitForPredicate` reports that as `pass: false`, and taking it at face value
17
+ // made Reticle blame the app, by file and line, for its own lost connection. Measured twice: a
18
+ // reload 300ms into a wait returned `verified:"no" / assertion_failed` against a healthy Counter.
19
+ //
20
+ // UNKNOWN, for the same reason `unclean_capture` is: the evidence is ABSENT, not negative.
21
+ if (true === inputs.observationLost) {
22
+ return {
23
+ verified: Verified.UNKNOWN,
24
+ verifiedReason: VerifiedReason.OBSERVATION_LOST,
25
+ because: 'the tab disconnected while this action was being observed, so its outcome was never ' +
26
+ 'seen — this says nothing about the app. Call reticle_sessions for the current session ' +
27
+ '(a reloaded tab keeps its id; a closed one is gone) and repeat the action if it is safe ' +
28
+ 'to repeat',
29
+ };
30
+ }
15
31
  // A failed assertion is the most actionable fact there is; it leads — including over
16
32
  // `alreadyTrue`, because a condition that held before AND fails now is a real regression.
17
33
  if (false === pass) {
@@ -25,14 +41,37 @@ export function decideVerified(inputs) {
25
41
  // this product exists to catch is a green assertion sitting on top of a failed write. Measured on
26
42
  // the bench app — `ui-advanced-request-failed` arrived with verdict.pass true and every other
27
43
  // channel agreeing. Letting `pass` win there would report exactly the false green being detected.
28
- if (contradictions.length > 0) {
29
- const kinds = contradictions.map((c) => c.kind).join(', ');
44
+ // ...but ONLY for contradictions we positively OBSERVED. A kind inferred from the absence of
45
+ // evidence — the request had not settled, the response had not been applied yet — is a statement
46
+ // about when we stopped looking, not about whether the action worked, and the window closes the
47
+ // moment the predicate first passes. Reproduced on the bench app: `auth:granted` fired with
48
+ // matching data, state changed, the token was stored, the capture was clean and the grade was
49
+ // `signal`, and the verdict was still NO because one POST was in flight. That inverts the grade
50
+ // hierarchy — a timing observation beat a consequence observation — and it is the shape of half of
51
+ // every `no` verdict in the field. See ABSENCE_DERIVED_CONTRADICTIONS for why a false negative
52
+ // costs more than it looks: it makes an agent redo work that succeeded, or stop believing the
53
+ // verdict channel, which is the product.
54
+ const observed = contradictions.filter((c) => !isAbsenceDerived(c.kind));
55
+ if (observed.length > 0) {
56
+ const kinds = observed.map((c) => c.kind).join(', ');
30
57
  return {
31
58
  verified: Verified.NO,
32
59
  verifiedReason: VerifiedReason.CONTRADICTED,
33
60
  because: `channels disagree about this action (${kinds}) even though the assertion passed`,
34
61
  };
35
62
  }
63
+ // Absence-derived only: report it, do not assert failure. UNKNOWN is the honest answer — Reticle
64
+ // drove the app and could not yet tell. The finding still rides out in `contradictions`, so an
65
+ // agent that wants to wait and re-check has everything it needs.
66
+ if (contradictions.length > 0) {
67
+ const kinds = contradictions.map((c) => c.kind).join(', ');
68
+ return {
69
+ verified: Verified.UNKNOWN,
70
+ verifiedReason: VerifiedReason.UNSETTLED,
71
+ because: `the assertion held, but this window closed before the app finished (${kinds}) — ` +
72
+ `re-check, or assert a consequence that settles`,
73
+ };
74
+ }
36
75
  // Below the contradiction clause, and for the same reason a contradiction outranks a dirty
37
76
  // capture: evidence AGAINST beats absence of evidence, whichever absence it is. Both can hold at
38
77
  // once — assert `{ text: 'Saved' }` that was already on screen while the write 500s — and ordering
@@ -1,6 +1,7 @@
1
1
  import * as http from 'node:http';
2
2
  import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
3
3
  import { MCP_SSE_PATH, MCP_MESSAGE_PATH, STATUS_PATH } from '@reticlehq/core';
4
+ import { getSessionMetrics } from './telemetry/session-metrics.js';
4
5
  import { noteAgentPeer, PEER_VERSION_PARAM, PEER_CONTRACT_PARAM } from './version/peer-announce.js';
5
6
  import { log } from './log.js';
6
7
  import { reportMcpConnected } from './telemetry/mcp-connection.js';
@@ -85,6 +86,14 @@ export function createSharedServer(options = {}) {
85
86
  transport.close().catch(() => undefined);
86
87
  mcpServer.close().catch(() => undefined);
87
88
  log('mcp_client_disconnected', { sessionId: sid });
89
+ // The one part of `endReason` a query cannot derive: "the client detached" and "the agent
90
+ // stopped asking" produce identical counters and are different findings.
91
+ try {
92
+ getSessionMetrics().recordClientLeft();
93
+ }
94
+ catch {
95
+ /* a counter must never affect a disconnect path */
96
+ }
88
97
  if (0 === transports.size)
89
98
  agentPresence?.(false); // last agent detached → it's the human's turn
90
99
  });
@@ -141,8 +150,18 @@ export function createSharedServer(options = {}) {
141
150
  transports.clear();
142
151
  await new Promise((resolve, reject) => {
143
152
  httpServer.close((err) => {
144
- if (err !== undefined && err !== null)
153
+ // A server that never listened is already closed, and saying so is not an error worth
154
+ // propagating. It matters because this close() runs on the CLEANUP path of a failed
155
+ // `--drive`: when the app URL is unreachable, the daemon tears down before `listen()` has
156
+ // been called, Node rejects with ERR_SERVER_NOT_RUNNING, and that rejection REPLACES the
157
+ // real cause. The user's app is down and the message they read is "Server is not running",
158
+ // which blames Reticle for their dev server — traced live, where swallowing this turned the
159
+ // same failure back into "net::ERR_CONNECTION_REFUSED at http://localhost:4312/".
160
+ if (err !== undefined &&
161
+ err !== null &&
162
+ 'ERR_SERVER_NOT_RUNNING' !== err.code) {
145
163
  reject(err);
164
+ }
146
165
  else
147
166
  resolve();
148
167
  });
package/dist/index.d.ts CHANGED
@@ -34,6 +34,7 @@ export type { Lease, Launcher, PooledBrowser } from './pool/browser-pool.js';
34
34
  export { playwrightLauncher, resolveMaxContexts } from './pool/playwright-launcher.js';
35
35
  export { appendReticleParams } from './tools/lease-tools.js';
36
36
  export { writePid, removePid, isRunning, logPath, readPid, isAlive } from './daemon/daemon.js';
37
+ export { classifyDaemonLife, DaemonEnd, DAEMON_HEARTBEAT_EVENT, DAEMON_HEARTBEAT_MS, type DaemonLife, } from './daemon/heartbeat.js';
37
38
  export type { CrawlReport, CrawlAnomaly, CrawlOptions, CrawlSession } from './crawl/crawl.js';
38
39
  export { scrollToFind } from './input/scroll-find.js';
39
40
  export type { ScrollFindResult, ScrollFindQuery, ScrollFindSession } from './input/scroll-find.js';