@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
@@ -6,11 +6,12 @@
6
6
  import { z } from 'zod';
7
7
  import { aliasParam } from './alias-args.js';
8
8
  import { captureAct, compileSequenceStep } from '../flows/replay.js';
9
- import { ActionType, ActionWarning, DEFAULT_ASSERT_TIMEOUT_MS, InputMode, ReticleCommand, Verified, PredicateKind, } from '@reticlehq/core';
9
+ import { ActionType, ActionWarning, CaptureLoss, DEFAULT_ASSERT_TIMEOUT_MS, InputMode, ReticleCommand, Verified, PredicateKind, } from '@reticlehq/core';
10
10
  import { assertNativeInputSupported } from './act-danger.js';
11
11
  import { leanActResult, mutatedWithin } from './act-view.js';
12
12
  import { ReticleTool } from './tool-names.js';
13
13
  import { buildReactionReport, summarizeReaction } from '../events/reaction.js';
14
+ import { parsePredicate } from '../events/predicate-parse.js';
14
15
  import { causalSummary } from '../capsule/causal-summary.js';
15
16
  import { findContradictions } from '../events/contradictions.js';
16
17
  import { decideVerified } from '../honesty/verified.js';
@@ -19,7 +20,7 @@ import { saveFailedAssertCapsule } from './act-capsule.js';
19
20
  import { buildDivergenceCapsule } from '../capsule/capsule.js';
20
21
  import { predicateToExpectedLinks } from '../capsule/predicate-to-links.js';
21
22
  import { buildHonestyBlock } from '../honesty/honesty.js';
22
- import { buildCoverageStatement, blindSpotsFromState, Coverage, impeachesCapture, } from '../honesty/blind-spots.js';
23
+ import { buildCoverageStatement, blindSpotsFromState, transportGapNote, Coverage, impeachesCapture, } from '../honesty/blind-spots.js';
23
24
  import { hasAcceptedWrite } from '../honesty/accepted-write.js';
24
25
  import { hasUnreadWriteOutcome } from '../honesty/unread-outcome.js';
25
26
  import { evaluatePredicate, waitForPredicate, provenExpectedLinks, PredicateSchema, } from '../events/predicate.js';
@@ -54,7 +55,7 @@ export const ACT_TOOLS = [
54
55
  {
55
56
  name: ReticleTool.ACT,
56
57
  example: { ref: 'e42', action: 'fill', args: { value: 'hello' } },
57
- description: 'Execute one action against a ref: click|dblclick|hover|focus|fill|type|clear|select|check|uncheck|submit|press|scrollIntoView. Returns immediately with a `since` cursor — observe the reaction with reticle_observe. For a VERDICT instead of a cursor use reticle_act_and_wait { until } — the same action plus the assertion, in one call; prefer it whenever the action is supposed to cause something. Carries effect:{dispatched,targetMatched,visible,enabled,focusMoved,valueChanged,domMutatedWithin,occluded,occludedBy,scrolledIntoView} to tell "action missed" from "app didn\'t react"; dispatched=landed, settled=a real frame flushed, and a settle timeout never fails the tool. Fields at their uninformative default are OMITTED so a clean action collapses to its consequence: an absent dispatched/targetMatched/visible/enabled means true, an absent occluded/scrolledIntoView/valueChanged/defaultPrevented means false, an absent focusMoved/occludedBy means null. occluded=true means the click point is covered by another element (a real user could not click it) — synthetic dispatch still delivered the event; scrolledIntoView=true means an off-viewport target was scrolled in first. inputMode is "real" (native CDP, no synthetic effect block) or "synthetic"; clicks default to the occlusion-honest synthetic path even when CDP is configured — pass args.native:true to force a trusted native click (file pickers, clipboard). inputModeReason explains any real→synthetic choice so it is never silent. Full model (real-input, throttled tabs, `reticle drive`): docs/usage.md §18.',
58
+ description: 'Act WITHOUT checking the result — if the action is supposed to cause something, use reticle_act_and_wait { until } instead and get the verdict in the same call. This tool returns immediately with a `since` cursor; observe the reaction yourself with reticle_observe. Use it for actions with no observable consequence to assert (focus, hover, scrollIntoView, a fill you are about to submit). One action against a ref: click|dblclick|hover|focus|fill|type|clear|select|check|uncheck|submit|press|scrollIntoView. Carries effect:{dispatched,targetMatched,visible,enabled,focusMoved,valueChanged,domMutatedWithin,occluded,occludedBy,scrolledIntoView} to tell "action missed" from "app didn\'t react"; dispatched=landed, settled=a real frame flushed, and a settle timeout never fails the tool. Fields at their uninformative default are OMITTED so a clean action collapses to its consequence: an absent dispatched/targetMatched/visible/enabled means true, an absent occluded/scrolledIntoView/valueChanged/defaultPrevented means false, an absent focusMoved/occludedBy means null. occluded=true means the click point is covered by another element (a real user could not click it) — synthetic dispatch still delivered the event; scrolledIntoView=true means an off-viewport target was scrolled in first. inputMode is "real" (native CDP, no synthetic effect block) or "synthetic"; clicks default to the occlusion-honest synthetic path even when CDP is configured — pass args.native:true to force a trusted native click (file pickers, clipboard). args.holdMs keeps the pointer DOWN for that long between mousedown and mouseup — the only way to drive a hold-to-confirm control (hold-to-delete, hold-to-record, long-press); effect.heldMs reports the hold actually achieved, which a throttled background tab can stretch. args.confirmDangerous is a permission gate and NOT a duration: it allows a destructive control, it does not hold one. inputModeReason explains any real→synthetic choice so it is never silent. Full model (real-input, throttled tabs, `reticle drive`): node_modules/@reticlehq/server/docs/usage.md §18.',
58
59
  inputSchema: {
59
60
  ref: z
60
61
  .string()
@@ -63,7 +64,7 @@ export const ACT_TOOLS = [
63
64
  args: z
64
65
  .record(z.unknown())
65
66
  .optional()
66
- .describe('Action-specific arguments: { value } for fill/select, { text } for type/press, { native: true } to force a trusted native click, { confirmDangerous: true } to allow a potentially destructive control.'),
67
+ .describe('Action-specific arguments: { value } for fill/select, { text } for type/press (the key NAME, e.g. Escape or Tab), { toRef } for drag (the ref to drop ON — without it the drag lands nowhere), { native: true } to force a trusted native click, { holdMs: N } to keep the pointer DOWN for N ms (hold-to-confirm controls; effect.heldMs reports what was achieved), { confirmDangerous: true } to allow a potentially destructive control — a permission gate, NOT a duration.'),
67
68
  refuseWhenThrottled: z
68
69
  .boolean()
69
70
  .optional()
@@ -172,7 +173,18 @@ export const ACT_TOOLS = [
172
173
  },
173
174
  {
174
175
  name: ReticleTool.ACT_SEQUENCE,
175
- description: 'Run multiple actions in order (fill -> fill -> submit) in one round-trip. Returns per-step effects[] (see reticle_act).',
176
+ // The example is required for a core tool, and this one carries weight: the measured loop it
177
+ // replaces is literally a login form driven as three separate reticle_act calls (98 clicks and
178
+ // 21 fills inside looping sessions, 2026-08-10/11). Showing fill -> fill -> click is showing the
179
+ // exact shape an agent otherwise spends three round trips on.
180
+ example: {
181
+ steps: [
182
+ { ref: 'e12', action: 'fill', args: { value: 'a@b.com' } },
183
+ { ref: 'e13', action: 'fill', args: { value: 'hunter2' } },
184
+ { ref: 'e14', action: 'click' },
185
+ ],
186
+ },
187
+ description: 'Run multiple actions in order (fill -> fill -> submit) in ONE round-trip. Prefer this over repeating reticle_act for a multi-step journey, then assert its consequence once. Returns per-step effects[] (see reticle_act).',
176
188
  inputSchema: {
177
189
  steps: z
178
190
  .array(z.record(z.unknown()))
@@ -243,7 +255,7 @@ export const ACT_TOOLS = [
243
255
  args: z
244
256
  .record(z.unknown())
245
257
  .optional()
246
- .describe('Action-specific arguments: { value } for fill/select, { text } for type/press, { confirmDangerous: true } for a potentially destructive control.'),
258
+ .describe('Action-specific arguments: { value } for fill/select, { text } for type/press (the key NAME, e.g. Escape or Tab), { toRef } for drag (the ref to drop ON — without it the drag lands nowhere), { confirmDangerous: true } for a potentially destructive control.'),
247
259
  predicate: PredicateSchema.optional().describe('Alias for `until` (the name reticle_assert / reticle_wait_for use).'),
248
260
  until: PredicateSchema.optional().describe('Predicate to wait for after the action completes (same shape as reticle_assert). OMIT to wait for the page to SETTLE — network + DOM idle — the deterministic default instead of a sleep. To assert a consequence AND settle, allOf them: { kind: "allOf", predicates: [<your predicate>, { kind: "settled" }] }.'),
249
261
  timeout_ms: z
@@ -264,6 +276,10 @@ export const ACT_TOOLS = [
264
276
  pass: z.boolean(),
265
277
  evidence: z.unknown().optional(),
266
278
  failureReason: z.string().optional(),
279
+ observationLost: z
280
+ .boolean()
281
+ .optional()
282
+ .describe('The tab disconnected mid-wait, so this was never observed — the verdict is UNKNOWN, not a failure of the app.'),
267
283
  // The STRUCTURED cause — observed / expected / assertion — is what the repair literature ranks
268
284
  // above the prose failureReason (structured feedback beat narrative by 10.5pp) and above a bare
269
285
  // pointer. `verdict` is `await waitForPredicate(...)`, whose EvalResult carries these on a
@@ -332,18 +348,13 @@ export const ACT_TOOLS = [
332
348
  // `predicate` is what reticle_assert / reticle_wait_for call this — see alias-args.ts.
333
349
  const withUntil = aliasParam(args, 'until', ['predicate']);
334
350
  const until = withUntil['until'] !== undefined
335
- ? PredicateSchema.parse(withUntil['until'])
351
+ ? parsePredicate(withUntil['until'])
336
352
  : { kind: PredicateKind.SETTLED };
337
353
  const timeout = asNumber(args['timeout_ms']) ?? DEFAULT_ASSERT_TIMEOUT_MS;
338
354
  // Before anything is driven: this path cannot honour a native-input request, and taking the
339
355
  // argument and ignoring it told the agent its trusted click had happened. See act-danger.
340
356
  assertNativeInputSupported(asRecord(args['args']));
341
357
  const since = session.elapsed();
342
- // `dropped` is cumulative for the SESSION, so comparing against a pre-action reading is what
343
- // asks "did the buffer lose anything while THIS action was observed?" — the question the
344
- // honesty block means. Reading the raw total pinned every later verdict to `unknown` after a
345
- // single early eviction (measured on the Next.js demo at dropped:51, windows intact).
346
- const droppedBefore = session.bufferHealth().dropped;
347
358
  // The cursor + effect are marked after the act dispatches (below) — a refused act leaves none.
348
359
  // The attribution window stays open across the settle wait below, so post-dispatch async events
349
360
  // (the whole point of act_and_wait) attribute to this action. finishAction fires after the wait.
@@ -404,12 +415,35 @@ export const ACT_TOOLS = [
404
415
  // structural boundary (virtualized rows, a cross-origin frame) is reported as coverage and
405
416
  // must not downgrade a verdict about what WAS observed.
406
417
  const impeaching = buildCoverageStatement(spots.filter((s) => impeachesCapture(s.kind)));
418
+ // Same rule as reticle_assert: a browser-side transport gap means part of this window was
419
+ // never seen, which is what `blindSpots` exists to say. `truncated` above covers the SERVER
420
+ // ring buffer evicting; this covers the BROWSER queue overflowing, and they are not the same
421
+ // loss — an act_and_wait that graded `proved` over 34 dropped events said so in a sentence
422
+ // whose own words were "over a clean capture".
423
+ const gapNote = transportGapNote(windowEvents);
424
+ const impeachingNotes = [impeaching.note, gapNote].filter((n) => n !== undefined);
425
+ const bufferLost = session.lostSince(since);
426
+ // Which loss, as an enum, beside the prose that describes it. Classified here because this is
427
+ // the only place that knows the three apart: our buffer, our transport, and the page's own
428
+ // boundaries. See `CaptureLoss`.
429
+ const losses = [
430
+ ...(bufferLost ? [CaptureLoss.BUFFER_LOSS] : []),
431
+ ...(gapNote === undefined ? [] : [CaptureLoss.TRANSPORT_GAP]),
432
+ ...(impeaching.note === undefined ? [] : [CaptureLoss.BLIND_SPOT]),
433
+ ];
407
434
  const honesty = buildHonestyBlock({
408
435
  grade: gradeOf(gradedLinks),
409
436
  attribution: 'window',
410
- truncated: session.bufferHealth().dropped > droppedBefore,
437
+ // Did the buffer lose scarce evidence FROM THIS WINDOW — not "did it evict anything while
438
+ // the action ran", which was the previous rule and which is true on essentially every live
439
+ // page. Age eviction retires everything past 60s on every push, and the churn floor is
440
+ // sacrificed on purpose; neither costs this verdict a single event it needed. In the
441
+ // field this made `unclean_capture` the dominant cause of `unknown`, reported for
442
+ // evictions that happened outside the window they impeached.
443
+ truncated: bufferLost,
411
444
  coveragePartial: Coverage.PARTIAL === coverage.coverage,
412
- ...(impeaching.note === undefined ? {} : { blindSpots: [impeaching.note] }),
445
+ ...(0 === impeachingNotes.length ? {} : { blindSpots: impeachingNotes }),
446
+ losses,
413
447
  });
414
448
  const capsuleSaved = await saveFailedAssertCapsule({
415
449
  deps,
@@ -423,8 +457,19 @@ export const ACT_TOOLS = [
423
457
  // Pass the action: an EMPTY window then reads as "the target does not react" rather than as a
424
458
  // clean run — `settled` is satisfied by a page that did nothing, i.e. by a click that missed.
425
459
  const acted = asString(args['action']);
460
+ // `prior` matters here for the same reason it matters on the assert path: a SCALE error
461
+ // disagrees with a value the API stated EARLIER in the session, which an action-scoped
462
+ // window can never contain. Without it `unit-mismatch` — the money false green, where a
463
+ // total renders 100x off — is structurally unreachable from `act_and_wait`, which is the
464
+ // tool the product tells agents to reach for first. Our best detector could not fire on our
465
+ // flagship path, and nothing said so: the finding was simply never produced.
466
+ //
467
+ // Learning material only. Attribution is unchanged — findings still come exclusively from
468
+ // `windowEvents`, exactly as on the assert path.
469
+ const prior = since > 0 ? (await session.queryEvents({ since: 0 })).filter((e) => e.t < since) : [];
426
470
  const contradictions = findContradictions(windowEvents, {
427
471
  action: acted,
472
+ prior,
428
473
  ...session.lastAct.effect(),
429
474
  });
430
475
  // The single field an agent reads. Everything below it is the evidence it was derived from;
@@ -436,6 +481,10 @@ export const ACT_TOOLS = [
436
481
  ...(alreadyTrue ? { alreadyTrue } : {}),
437
482
  // An assertion nobody could evaluate must not be reported as one the app failed.
438
483
  ...(verdict.inconclusive === undefined ? {} : { inconclusive: verdict.inconclusive }),
484
+ // Nor must one nobody could OBSERVE. This is the act path, so it is the one that produced
485
+ // the measured false red: a reload mid-wait, graded assertion_failed at the clicked
486
+ // component's own file and line.
487
+ ...(true === verdict.observationLost ? { observationLost: true } : {}),
439
488
  honesty,
440
489
  contradictions,
441
490
  ...(outcomePending ? { outcomePending } : {}),
@@ -16,7 +16,13 @@ import { type Contradiction } from '../events/contradictions.js';
16
16
  */
17
17
  export declare function assertVerdict(session: Session, predicate: Predicate, pass: boolean, since: number,
18
18
  /** Set when the assertion was never evaluated — see honesty/verified.ts. */
19
- inconclusive?: string): Promise<{
19
+ inconclusive?: string,
20
+ /**
21
+ * Set when the tab went away mid-wait, so the assertion was never OBSERVED. `reticle_assert` and
22
+ * `reticle_wait_for` reach the same disconnect path as the act tools, and a verdict that blames
23
+ * the app for a lost connection is no more honest on this route than on that one.
24
+ */
25
+ observationLost?: boolean): Promise<{
20
26
  decision: Record<string, unknown>;
21
27
  contradictions: Contradiction[];
22
28
  coverage: Record<string, unknown>;
@@ -1,5 +1,6 @@
1
+ import { CaptureLoss } from '@reticlehq/core';
1
2
  import { findContradictions } from '../events/contradictions.js';
2
- import { blindSpotsFromState, buildCoverageStatement, Coverage, impeachesCapture, } from '../honesty/blind-spots.js';
3
+ import { blindSpotsFromState, buildCoverageStatement, Coverage, impeachesCapture, transportGapNote, } from '../honesty/blind-spots.js';
3
4
  import { buildHonestyBlock } from '../honesty/honesty.js';
4
5
  import { hasAcceptedWrite } from '../honesty/accepted-write.js';
5
6
  import { hasUnreadWriteOutcome } from '../honesty/unread-outcome.js';
@@ -20,7 +21,13 @@ import { gradeOfPredicate } from './assert-grade.js';
20
21
  */
21
22
  export async function assertVerdict(session, predicate, pass, since,
22
23
  /** Set when the assertion was never evaluated — see honesty/verified.ts. */
23
- inconclusive) {
24
+ inconclusive,
25
+ /**
26
+ * Set when the tab went away mid-wait, so the assertion was never OBSERVED. `reticle_assert` and
27
+ * `reticle_wait_for` reach the same disconnect path as the act tools, and a verdict that blames
28
+ * the app for a lost connection is no more honest on this route than on that one.
29
+ */
30
+ observationLost) {
24
31
  // Scope caveat, stated because it is a real limitation and not a bug: blind spots are tracked per
25
32
  // SESSION, not per assertion window, so a cross-origin iframe seen once marks every later verdict
26
33
  // in that session partial — including ones about a region it cannot affect. That errs toward
@@ -46,16 +53,28 @@ inconclusive) {
46
53
  // Only a spot that IMPEACHES the capture downgrades a verdict. A structural boundary (virtualized
47
54
  // rows, a cross-origin frame) is reported as coverage and must not impugn what WAS observed.
48
55
  const impeaching = buildCoverageStatement(spots.filter((sp) => impeachesCapture(sp.kind)));
56
+ // A gap in the WINDOW, as opposed to a standing limit of the page. Both mean the same thing to the
57
+ // rule — part of what happened was not seen — so both belong in `blindSpots`, which is the only
58
+ // input `decideVerified` reads for that.
59
+ const gap = transportGapNote(windowEvents);
60
+ const impeachingNotes = [impeaching.note, gap].filter((n) => n !== undefined);
49
61
  const outcomePending = hasAcceptedWrite(windowEvents);
50
62
  const outcomeUnread = hasUnreadWriteOutcome(windowEvents);
51
63
  const decision = decideVerified({
52
64
  pass,
53
65
  ...(inconclusive === undefined ? {} : { inconclusive }),
66
+ ...(true === observationLost ? { observationLost: true } : {}),
54
67
  honesty: buildHonestyBlock({
55
68
  grade: gradeOfPredicate(predicate),
56
69
  attribution: 'window',
57
70
  coveragePartial: Coverage.PARTIAL === statement.coverage,
58
- ...(impeaching.note === undefined ? {} : { blindSpots: [impeaching.note] }),
71
+ ...(0 === impeachingNotes.length ? {} : { blindSpots: impeachingNotes }),
72
+ // Which loss, as an enum, beside the prose. `assert` observes an already-open window and never
73
+ // consults the ring buffer's health, so `buffer_loss` is not one of its answers.
74
+ losses: [
75
+ ...(gap === undefined ? [] : [CaptureLoss.TRANSPORT_GAP]),
76
+ ...(impeaching.note === undefined ? [] : [CaptureLoss.BLIND_SPOT]),
77
+ ],
59
78
  }),
60
79
  contradictions,
61
80
  ...(outcomePending ? { outcomePending } : {}),
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { navigateResult } from './navigate-result.js';
3
+ import { awaitArrival } from './navigate-arrival.js';
3
4
  import { reloadResult } from './reload-result.js';
4
5
  import { waitForReconnect, RELOAD_RECONNECT_TIMEOUT_MS } from '../session/session-reconnect.js';
5
6
  import { ReticleCommand } from '@reticlehq/core';
@@ -10,7 +11,7 @@ export const BROWSER_TOOLS = [
10
11
  {
11
12
  name: ReticleTool.NAVIGATE,
12
13
  example: { url: '/settings' },
13
- description: 'Navigate the connected browser tab to a URL, or reload it in place with { reload: true } (add { hard: true } to bypass the cache). `ok` means the navigation was DISPATCHED, not that the page arrived — the SDK is torn down by the navigation, so nothing can observe the new document. Call reticle_sessions to confirm a session reconnected at the new URL before acting.',
14
+ description: 'Navigate the connected browser tab to a URL, or reload it in place with { reload: true } (add { hard: true } to bypass the cache). `ok` means the navigation was DISPATCHED — the SDK is torn down by the navigation, so the page itself cannot report on it. The daemon then waits briefly for the SDK to reconnect: `confirmed:true` with a new `sessionId` means the page arrived and you can act immediately. `confirmed:false` means it did not arrive within the window — the page may be slow, uninstrumented, or not there; check reticle_sessions before acting.',
14
15
  inputSchema: {
15
16
  url: z.string().optional().describe('The URL to navigate to. Omit when using reload.'),
16
17
  reload: z
@@ -30,6 +31,8 @@ export const BROWSER_TOOLS = [
30
31
  // Present on a dispatched navigation: nothing here can see the new document, so arrival is
31
32
  // reported as unconfirmed rather than implied by `ok`. See navigate-result.ts.
32
33
  confirmed: z.boolean().optional(),
34
+ /** The session the SDK reconnected as, when arrival was confirmed — it is a NEW id. */
35
+ sessionId: z.string().optional(),
33
36
  note: z.string().optional(),
34
37
  },
35
38
  handler: async (deps, args) => {
@@ -65,7 +68,10 @@ export const BROWSER_TOOLS = [
65
68
  try {
66
69
  const result = (await commandOrThrow(deps, asString(args['sessionId']), ReticleCommand.NAVIGATE, { url }));
67
70
  // `ok` is the browser accepting the instruction, not the page arriving — see navigate-result.
68
- return navigateResult(result);
71
+ // The daemon is the only party that CAN see arrival (the SDK reconnects to it), so it looks,
72
+ // briefly, instead of telling the agent to go poll reticle_sessions itself.
73
+ const arrival = true === result.ok ? await awaitArrival(deps.sessions, url) : null;
74
+ return navigateResult(result, arrival);
69
75
  }
70
76
  finally {
71
77
  session.finishAction();
@@ -1,7 +1,2 @@
1
1
  import type { ToolDef } from './tools.js';
2
- /**
3
- * The capability-contract tools. `reticle_capabilities` reads the live session, or the
4
- * git-checked `.reticle/contract.json` when `{ fromDisk:true }`; `reticle_contract_save` persists the
5
- * live registry to that file (pretty-printed, stable key order — diffable in PRs).
6
- */
7
2
  export declare const CONTRACT_TOOLS: ToolDef[];
@@ -9,6 +9,31 @@ import { reticleDirPaths, readContract, writeContract } from '../project/reticle
9
9
  * git-checked `.reticle/contract.json` when `{ fromDisk:true }`; `reticle_contract_save` persists the
10
10
  * live registry to that file (pretty-printed, stable key order — diffable in PRs).
11
11
  */
12
+ /**
13
+ * Refuse to persist one project's surface into another project's repo.
14
+ *
15
+ * `writeContract` writes to the DAEMON's `.reticle/`, which is the directory the daemon was started
16
+ * in — not the session's project. A daemon started above several apps therefore saves app A's
17
+ * testids into app B's checkout, reports `saved: true`, and hands back a path that looks right. A
18
+ * contract is git-checked and diffable, so that is a change somebody commits.
19
+ *
20
+ * Refuse rather than redirect: a session advertises a projectId, not a project DIRECTORY, so there
21
+ * is no honest way to work out where the correct `.reticle/` lives. Naming both ids and stopping is
22
+ * the whole of what can be said truthfully.
23
+ *
24
+ * Silence is not a mismatch. An unstamped build carries no projectId, and a daemon above an
25
+ * uninitialised directory has none either; neither is evidence of two projects.
26
+ */
27
+ function assertSameProject(deps, sessionId) {
28
+ const mine = deps.projectId;
29
+ const theirs = deps.sessions.resolve(sessionId).projectId;
30
+ if (mine === undefined || theirs === undefined || mine === theirs)
31
+ return;
32
+ throw new Error(`refusing to save: this session belongs to project '${theirs}', but this daemon writes to ` +
33
+ `project '${mine}' (.reticle/ in the directory it was started in). Saving would put one ` +
34
+ "app's testable surface into another app's git-checked contract. Run a daemon from " +
35
+ `'${theirs}'s own directory and save there.`);
36
+ }
12
37
  export const CONTRACT_TOOLS = [
13
38
  {
14
39
  name: ReticleTool.CAPABILITIES,
@@ -54,6 +79,7 @@ export const CONTRACT_TOOLS = [
54
79
  handler: async (deps, args) => {
55
80
  const res = await commandOrThrow(deps, asString(args['sessionId']), ReticleCommand.CAPABILITIES, {});
56
81
  const caps = CapabilitiesSchema.parse(res);
82
+ assertSameProject(deps, asString(args['sessionId']));
57
83
  await writeContract(deps.fs, deps.reticleRoot, caps, deps.now);
58
84
  return {
59
85
  saved: true,
@@ -141,7 +141,12 @@ export function buildDynamicTools(allTools, profile) {
141
141
  if (target === undefined) {
142
142
  // A non-zero count here means our advertised surface is confusing the agent — it reached for
143
143
  // something it believed existed. That is a docs/naming defect, and it was invisible.
144
- getSessionMetrics().recordUnknownTool();
144
+ //
145
+ // The NAME goes with it: the count says the surface confused someone, the name says which
146
+ // capability they expected. It was already in hand here and thrown away, so the one place
147
+ // the product could learn what agents want was discarding it. Safe — a name from our own
148
+ // tool namespace, never app data.
149
+ getSessionMetrics().recordUnknownTool(name);
145
150
  // ...and for 22 of those names the capability DOES exist, it just moved when tools merged.
146
151
  // Answering "unknown tool" there is simply wrong; see merged-name-redirect.
147
152
  const moved = mergedNameRedirect(name);
@@ -40,6 +40,20 @@ export declare const RECOVERY: {
40
40
  * saying a server is running and the recovery saying to go start one.
41
41
  */
42
42
  export declare function recoveryFor(message: string): string | undefined;
43
+ /**
44
+ * A serialized zod issue array, rendered as a sentence. Anything else is returned untouched.
45
+ *
46
+ * This is the one boundary every agent-visible tool failure crosses, which is why the rewrite lives
47
+ * here rather than in each tool. `parsePredicate` solved this for the three predicate tools by never
48
+ * producing the array; it cannot help anywhere else, because the MCP SDK validates a tool's schema
49
+ * BEFORE our handler runs. So `reticle_session` with no action — the commonest mistake there is —
50
+ * never reaches the friendly "unknown action … expected: [tune, yield, …]" its handler already
51
+ * builds, and the agent gets the raw array instead. Driven live against the shipped daemon.
52
+ *
53
+ * Conservative by construction: it only rewrites a JSON ARRAY whose every element carries both
54
+ * `code` and `message`. A tool that legitimately returns JSON is left alone.
55
+ */
56
+ export declare function zodArrayAsSentence(message: string): string;
43
57
  /**
44
58
  * The ask attached to errors we do NOT recognize. An unrecognized error is, by definition, the case
45
59
  * where Reticle failed in a way nobody anticipated — the single highest-value moment to hear from the
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { TRANSPORT_LIMITS } from '@reticlehq/core';
12
12
  import { SELF_RECOVERING_MARKER } from '../session/no-session-diagnosis.js';
13
+ import { chromiumInstallCommand, bundledPlaywrightVersion } from '../cli/chromium-hint.js';
13
14
  /** Marks where an over-long message had its middle removed. Named so tests assert it by value. */
14
15
  const ELISION = '… [elided] …';
15
16
  /**
@@ -40,7 +41,13 @@ function capMessage(message) {
40
41
  * it was still collecting the defect ask, inviting a bug report about a refusal that had just told
41
42
  * the agent precisely what to do.
42
43
  */
43
- const SELF_RECOVERING = [/cannot drive native input/i];
44
+ const SELF_RECOVERING = [
45
+ /cannot drive native input/i,
46
+ // The unknown-session refusal now lists the live ids inline. Appending "call reticle_sessions for
47
+ // the current ids" to a message that just named them contradicts the shorter path it offers, and
48
+ // a contradicted instruction is how one agent came to retry a dead id twelve times.
49
+ /Connected right now:/i,
50
+ ];
44
51
  /** A message that already carries its own concrete next action, so nothing should be appended. */
45
52
  function isSelfRecovering(message) {
46
53
  if (message.includes(SELF_RECOVERING_MARKER))
@@ -123,7 +130,11 @@ export const RECOVERY = {
123
130
  'where it actually is. This is app/flow drift, which is exactly what the flow exists to catch.',
124
131
  NO_POOL: 'Reticle cannot launch its own browser here: the daemon-managed pool is absent, or Playwright ' +
125
132
  'has no Chromium installed (the message above says which). Run Reticle via `reticle mcp`, and ' +
126
- 'ask the human for `npx playwright install chromium` if that is what is missing. Meanwhile drive ' +
133
+ // Pinned to the bundled playwright: the unpinned command installs a revision this daemon never
134
+ // looks at, so the human runs it, it succeeds, and nothing changes.
135
+ 'ask the human for `' +
136
+ chromiumInstallCommand(bundledPlaywrightVersion()) +
137
+ '` if that is what is missing. Meanwhile drive ' +
127
138
  'a tab the human already has open — reticle_sessions lists them.',
128
139
  };
129
140
  /** Ordered match rules; the first hit wins. Substrings track the thrown messages they recover. */
@@ -167,8 +178,13 @@ const RULES = [
167
178
  match: /potentially destructive (?:\w+ )*(?:action|tool) blocked/i,
168
179
  hint: RECOVERY.CONFIRM_DANGEROUS,
169
180
  },
181
+ // Split in two because `^` binds to the FIRST alternative only. As one pattern this read as though
182
+ // the anchor governed all four spellings; it never did, and the next person to add a fifth would
183
+ // have had a coin-flip's chance of getting the behaviour they intended. The anchored spelling is
184
+ // one Reticle authors at the start of its own message; the other three appear mid-message.
185
+ { match: /^cannot [\w()]+ (?:a|an|into a|on a) </i, hint: RECOVERY.WRONG_TARGET },
170
186
  {
171
- match: /^cannot [\w()]+ (?:a|an|into a|on a) <|no form to submit|upload target must be|is not an HTMLElement/i,
187
+ match: /no form to submit|upload target must be|is not an HTMLElement/i,
172
188
  hint: RECOVERY.WRONG_TARGET,
173
189
  },
174
190
  // Authored by Reticle, about the caller's arguments: the message already names the valid answers.
@@ -205,8 +221,74 @@ export function recoveryFor(message) {
205
221
  *
206
222
  * Matched by the SHAPES the validators actually produce (zod's codes, and the MCP layer's wrapper),
207
223
  * because these are our own machinery talking, not the app.
224
+ *
225
+ * `did not parse` is the shape `parsePredicate` produces. It was added because removing the raw zod
226
+ * array from that path ALSO removed the codes this pattern keys on — so a malformed predicate
227
+ * stopped being recognised as the agent's mistake and started being blamed on Reticle, complete with
228
+ * a request for a bug report. The e2e battery caught it (`no bad argument is blamed on Reticle`);
229
+ * no unit test could, which is why one now exists below.
230
+ */
231
+ /** Max issues rendered — a union rejection can emit one per member, which is how the array became unreadable. */
232
+ const MAX_RENDERED_ISSUES = 3;
233
+ function isZodIssue(value) {
234
+ if (typeof value !== 'object' || null === value)
235
+ return false;
236
+ const v = value;
237
+ return 'string' === typeof v['code'] && 'string' === typeof v['message'];
238
+ }
239
+ /** `["net","urlContains"]` → `net.urlContains`; an empty path means the object itself. */
240
+ function issueClause(issue) {
241
+ if ('unrecognized_keys' === issue.code && Array.isArray(issue.keys)) {
242
+ return `unknown field ${issue.keys.map(String).join(', ')}`;
243
+ }
244
+ const path = issue.path ?? [];
245
+ return `${0 === path.length ? 'the arguments' : path.map(String).join('.')}: ${issue.message}`;
246
+ }
247
+ /**
248
+ * A serialized zod issue array, rendered as a sentence. Anything else is returned untouched.
249
+ *
250
+ * This is the one boundary every agent-visible tool failure crosses, which is why the rewrite lives
251
+ * here rather than in each tool. `parsePredicate` solved this for the three predicate tools by never
252
+ * producing the array; it cannot help anywhere else, because the MCP SDK validates a tool's schema
253
+ * BEFORE our handler runs. So `reticle_session` with no action — the commonest mistake there is —
254
+ * never reaches the friendly "unknown action … expected: [tune, yield, …]" its handler already
255
+ * builds, and the agent gets the raw array instead. Driven live against the shipped daemon.
256
+ *
257
+ * Conservative by construction: it only rewrites a JSON ARRAY whose every element carries both
258
+ * `code` and `message`. A tool that legitimately returns JSON is left alone.
259
+ */
260
+ export function zodArrayAsSentence(message) {
261
+ const text = message.trim();
262
+ if (!text.startsWith('['))
263
+ return message;
264
+ let parsed;
265
+ try {
266
+ parsed = JSON.parse(text);
267
+ }
268
+ catch {
269
+ return message; // not JSON at all — e.g. prose that happens to open with a bracket
270
+ }
271
+ if (!Array.isArray(parsed) || 0 === parsed.length || !parsed.every(isZodIssue))
272
+ return message;
273
+ const shown = parsed.slice(0, MAX_RENDERED_ISSUES).map(issueClause).join('; ');
274
+ const rest = parsed.length - MAX_RENDERED_ISSUES;
275
+ return rest > 0 ? `${shown} (and ${String(rest)} more)` : shown;
276
+ }
277
+ /**
278
+ * `unknown field` and `<path>: Required` are the shapes `zodArrayAsSentence` produces.
279
+ *
280
+ * Added for the same reason `did not parse` was: rendering the array as a sentence REMOVES the very
281
+ * codes (`invalid_type`, `unrecognized_keys`) this pattern matched on, so a schema rejection fell
282
+ * through to the generic branch and came back as "may be a defect in Reticle" — worse than the dump
283
+ * it replaced, because it also asks for a bug report about the agent's own malformed call. This repo
284
+ * has now made that mistake twice; the second time a test caught it before CI did.
285
+ *
286
+ * Widened a THIRD time for PR #182, which formats the MCP SDK's validation error at the argument
287
+ * boundary — "Missing required parameter for reticle_session: action (one of: …)". That is better
288
+ * prose than the field-level sentence below (it names the TOOL and the allowed VALUES) and it was
289
+ * authored before this formatter existed, so it must not be punished for arriving first.
208
290
  */
209
- const ARGUMENT_REJECTION = /Unrecognized key|unrecognized_keys|invalid_type|Invalid arguments for tool|Unknown parameters? for|^Required |Expected \w+, received/i;
291
+ const ARGUMENT_REJECTION = /Unrecognized key|unrecognized_keys|invalid_type|did not parse|unknown field|: Required\b|Missing required parameter|Invalid parameter for|Invalid arguments for tool|Unknown parameters? for|^Required |Expected \w+, received/i;
210
292
  /**
211
293
  * What to say instead of asking for a bug report. The schema already stated the problem precisely;
212
294
  * this only says who can fix it and where to look.
@@ -237,10 +319,9 @@ export const FEEDBACK_ASK = 'This error is not one Reticle recognizes, which mea
237
319
  * agent always gets exactly one next move.
238
320
  */
239
321
  export function buildErrorPayload(rawMessage) {
240
- // Cap FIRST, then classify on the capped text — so what the classifier reads is exactly what the
241
- // agent reads. Classifying the full message and emitting a shorter one would let a recovery hint
242
- // reference a phrase that is no longer there.
243
- const message = capMessage(rawMessage);
322
+ // Render the zod array BEFORE capping: the cap would otherwise truncate the JSON mid-issue and
323
+ // leave an unparseable fragment as the agent's error text — the worst of both shapes.
324
+ const message = capMessage(zodArrayAsSentence(rawMessage));
244
325
  // A self-diagnosing message gets NEITHER a generic recovery nor the feedback ask. It already
245
326
  // inspected the machine and named the cause; a second, contradictory hint is noise, and inviting a
246
327
  // bug report about a condition Reticle diagnosed itself is exactly backwards.
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The one line that tells an agent it can push back — worded for what just happened.
3
+ *
4
+ * Feedback is the highest-yield channel this product has: **14 reports over two days produced
5
+ * roughly 11 of the fixes in 2.6.0**, against ~6,100 numeric events that produced a funnel which
6
+ * turned out to be broken. Prose from an agent at the moment of friction beats any structured field
7
+ * we could add.
8
+ *
9
+ * It reached those 14 through a standing instruction in the MCP handshake — paid once at connect.
10
+ * The gap that leaves is the agent who hits something mid-task and is concentrating on the problem
11
+ * rather than on remembering that a feedback tool exists.
12
+ *
13
+ * So: invite at the moment of friction, and vary the wording. A fixed string repeated forty times
14
+ * is invisible by call five; a line that names what just went wrong is read because it is about the
15
+ * thing the agent is already looking at. ~12 tokens against a ~4,546 tok/turn surface.
16
+ *
17
+ * Every invite is counted (`feedbackPrompted`). If `feedback_submitted ÷ feedbackPrompted` stays
18
+ * flat, this is decoration and should be deleted — instrumenting our own nudge is the difference
19
+ * between a designed system and a hope.
20
+ */
21
+ /** What just happened, as far as the chokepoint can tell. Ordered most specific first. */
22
+ export declare const FrictionKind: {
23
+ /** A tool that does not exist — the agent named a capability it expected us to have. */
24
+ readonly UNKNOWN_TOOL: "unknown_tool";
25
+ /** A verdict came back `unknown`: we drove the app and could not tell what happened. */
26
+ readonly UNKNOWN_VERDICT: "unknown_verdict";
27
+ /** The same tool, several times in a row. */
28
+ readonly REPEATING: "repeating";
29
+ /** The tool refused or errored. */
30
+ readonly REFUSED: "refused";
31
+ };
32
+ export type FrictionKind = (typeof FrictionKind)[keyof typeof FrictionKind];
33
+ /**
34
+ * Which friction, if any, this result represents.
35
+ *
36
+ * Deliberately reads only what the chokepoint already has: no handler changes, and nothing here can
37
+ * see app data.
38
+ */
39
+ export declare function frictionOf(facts: {
40
+ unknownTool: boolean;
41
+ verifiedUnknown: boolean;
42
+ repeatRun: number;
43
+ errored: boolean;
44
+ /** Did this call produce a verdict? A verdict is progress, and progress is not being stuck. */
45
+ producedVerdict: boolean;
46
+ }): FrictionKind | undefined;
47
+ /** The line for a friction kind. */
48
+ export declare function inviteFor(kind: FrictionKind): string;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The one line that tells an agent it can push back — worded for what just happened.
3
+ *
4
+ * Feedback is the highest-yield channel this product has: **14 reports over two days produced
5
+ * roughly 11 of the fixes in 2.6.0**, against ~6,100 numeric events that produced a funnel which
6
+ * turned out to be broken. Prose from an agent at the moment of friction beats any structured field
7
+ * we could add.
8
+ *
9
+ * It reached those 14 through a standing instruction in the MCP handshake — paid once at connect.
10
+ * The gap that leaves is the agent who hits something mid-task and is concentrating on the problem
11
+ * rather than on remembering that a feedback tool exists.
12
+ *
13
+ * So: invite at the moment of friction, and vary the wording. A fixed string repeated forty times
14
+ * is invisible by call five; a line that names what just went wrong is read because it is about the
15
+ * thing the agent is already looking at. ~12 tokens against a ~4,546 tok/turn surface.
16
+ *
17
+ * Every invite is counted (`feedbackPrompted`). If `feedback_submitted ÷ feedbackPrompted` stays
18
+ * flat, this is decoration and should be deleted — instrumenting our own nudge is the difference
19
+ * between a designed system and a hope.
20
+ */
21
+ /** What just happened, as far as the chokepoint can tell. Ordered most specific first. */
22
+ export const FrictionKind = {
23
+ /** A tool that does not exist — the agent named a capability it expected us to have. */
24
+ UNKNOWN_TOOL: 'unknown_tool',
25
+ /** A verdict came back `unknown`: we drove the app and could not tell what happened. */
26
+ UNKNOWN_VERDICT: 'unknown_verdict',
27
+ /** The same tool, several times in a row. */
28
+ REPEATING: 'repeating',
29
+ /** The tool refused or errored. */
30
+ REFUSED: 'refused',
31
+ };
32
+ /**
33
+ * Runs of one tool before it counts as being stuck.
34
+ *
35
+ * Three, matching the verdict nudge: `reticle_act` led the field data at 110 consecutive-repeat
36
+ * runs, and a run of three is where a deliberate retry becomes a loop.
37
+ */
38
+ const REPEAT_INVITE_AT = 3;
39
+ const LINES = {
40
+ [FrictionKind.UNKNOWN_TOOL]: 'no such tool — reticle_feedback tells us what you expected it to do, which is how it gets built',
41
+ [FrictionKind.UNKNOWN_VERDICT]: 'Reticle could not tell what happened here. If you expected otherwise, reticle_feedback — an ' +
42
+ 'unknown verdict is our defect, not yours',
43
+ [FrictionKind.REPEATING]: 'stuck on the same call? reticle_feedback — say what you were trying to do and it gets fixed',
44
+ [FrictionKind.REFUSED]: 'if that diagnosis was wrong or unhelpful, reticle_feedback — error messages are a product ' +
45
+ 'surface and we fix the ones that fail',
46
+ };
47
+ /**
48
+ * Which friction, if any, this result represents.
49
+ *
50
+ * Deliberately reads only what the chokepoint already has: no handler changes, and nothing here can
51
+ * see app data.
52
+ */
53
+ export function frictionOf(facts) {
54
+ if (facts.unknownTool)
55
+ return FrictionKind.UNKNOWN_TOOL;
56
+ if (facts.verifiedUnknown)
57
+ return FrictionKind.UNKNOWN_VERDICT;
58
+ // Repetition is only friction when it is repetition WITHOUT PROGRESS. Found by driving the Tauri
59
+ // smoke app: three consecutive SUCCESSFUL act_and_wait calls, each producing a verdict, were
60
+ // answered with "stuck on the same call?" — nagging the exact loop this release exists to
61
+ // encourage. A verdict is progress.
62
+ if (facts.repeatRun >= REPEAT_INVITE_AT && !facts.producedVerdict)
63
+ return FrictionKind.REPEATING;
64
+ if (facts.errored)
65
+ return FrictionKind.REFUSED;
66
+ return undefined;
67
+ }
68
+ /** The line for a friction kind. */
69
+ export function inviteFor(kind) {
70
+ return LINES[kind];
71
+ }