@reticlehq/server 2.11.0 → 2.12.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 (252) hide show
  1. package/SKILL.md +26 -2
  2. package/dist/bridge/bridge.d.ts +0 -13
  3. package/dist/bridge/bridge.js +1 -1
  4. package/dist/bridge/bridge.test-harness.d.ts +2 -0
  5. package/dist/bridge/bridge.test-harness.js +8 -1
  6. package/dist/capsule/capsule-store.d.ts +2 -1
  7. package/dist/capsule/capsule-store.js +1 -1
  8. package/dist/capsule/causal-summary.d.ts +2 -2
  9. package/dist/chromium-launch-options.d.ts +28 -0
  10. package/dist/chromium-launch-options.js +29 -0
  11. package/dist/cli/cli-doctor.js +8 -0
  12. package/dist/cli/cli-flow-commands.d.ts +3 -1
  13. package/dist/cli/cli-flow-commands.js +34 -4
  14. package/dist/cli/cli-kill.d.ts +2 -1
  15. package/dist/cli/cli-parse.d.ts +3 -1
  16. package/dist/cli/cli-parse.js +29 -7
  17. package/dist/cli/cli-update-commands.js +20 -1
  18. package/dist/cli/cli-verify.d.ts +1 -1
  19. package/dist/cli/cli-verify.js +2 -2
  20. package/dist/cli/cloud-cli.js +12 -6
  21. package/dist/cli/config-discovery.d.ts +2 -1
  22. package/dist/cli/doctor-rows.d.ts +3 -1
  23. package/dist/cli/doctor-rows.js +3 -1
  24. package/dist/cli/gate-exit.d.ts +21 -0
  25. package/dist/cli/gate-exit.js +21 -0
  26. package/dist/cli/gate-hook-message.d.ts +33 -0
  27. package/dist/cli/gate-hook-message.js +46 -0
  28. package/dist/cli/mcp-command.d.ts +25 -0
  29. package/dist/cli/mcp-command.js +143 -0
  30. package/dist/cli/sibling-ports.d.ts +37 -0
  31. package/dist/cli/sibling-ports.js +56 -0
  32. package/dist/cli.js +37 -110
  33. package/dist/cloud/cloud-config.d.ts +2 -1
  34. package/dist/cloud/cloud-sync.d.ts +11 -0
  35. package/dist/cloud/cloud-sync.js +32 -0
  36. package/dist/crawl/crawl.d.ts +9 -13
  37. package/dist/crawl/crawl.js +20 -3
  38. package/dist/daemon/daemon-resolve.d.ts +79 -0
  39. package/dist/daemon/daemon-resolve.js +118 -0
  40. package/dist/daemon/free-port.d.ts +29 -0
  41. package/dist/daemon/free-port.js +55 -0
  42. package/dist/daemon/heartbeat.d.ts +0 -4
  43. package/dist/daemon/heartbeat.js +2 -2
  44. package/dist/dev/stale-issue-guard.d.ts +0 -2
  45. package/dist/dev/stale-issue-guard.js +1 -1
  46. package/dist/events/contradictions.d.ts +41 -0
  47. package/dist/events/contradictions.js +125 -12
  48. package/dist/events/declared.d.ts +14 -0
  49. package/dist/events/declared.js +33 -0
  50. package/dist/events/echo-mismatch.js +14 -0
  51. package/dist/events/observed-in-window.d.ts +12 -0
  52. package/dist/events/observed-in-window.js +17 -0
  53. package/dist/events/predicate-asks.d.ts +13 -0
  54. package/dist/events/predicate-asks.js +22 -0
  55. package/dist/events/predicate-eval.js +64 -22
  56. package/dist/events/predicate-parse.d.ts +0 -6
  57. package/dist/events/predicate-parse.js +43 -3
  58. package/dist/events/predicate-schema.d.ts +44 -0
  59. package/dist/events/predicate-schema.js +126 -23
  60. package/dist/events/predicate.d.ts +15 -0
  61. package/dist/events/predicate.js +41 -2
  62. package/dist/flows/coverage.d.ts +0 -8
  63. package/dist/flows/coverage.js +1 -1
  64. package/dist/flows/decision.d.ts +5 -1
  65. package/dist/flows/decision.js +33 -7
  66. package/dist/flows/flake.d.ts +0 -2
  67. package/dist/flows/flake.js +1 -1
  68. package/dist/flows/flow-intent.d.ts +52 -0
  69. package/dist/flows/flow-intent.js +123 -0
  70. package/dist/flows/flow-replay-run.js +27 -4
  71. package/dist/flows/flow-success.js +15 -3
  72. package/dist/flows/flow-tools.js +32 -4
  73. package/dist/flows/flows.d.ts +6 -1
  74. package/dist/flows/flows.js +34 -22
  75. package/dist/flows/predicate-to-expect.js +6 -3
  76. package/dist/flows/server-verify.js +16 -2
  77. package/dist/flows/verify-change-tools.js +4 -1
  78. package/dist/honesty/blind-spots.d.ts +0 -17
  79. package/dist/honesty/blind-spots.js +1 -1
  80. package/dist/honesty/feature-capture.d.ts +150 -0
  81. package/dist/honesty/feature-capture.js +233 -0
  82. package/dist/honesty/instrumentation-gaps.d.ts +30 -6
  83. package/dist/honesty/instrumentation-gaps.js +61 -5
  84. package/dist/honesty/tool-hit-rate.d.ts +86 -0
  85. package/dist/honesty/tool-hit-rate.js +90 -0
  86. package/dist/honesty/undeclared-change.d.ts +51 -0
  87. package/dist/honesty/undeclared-change.js +56 -0
  88. package/dist/honesty/verified.d.ts +15 -0
  89. package/dist/honesty/verified.js +68 -4
  90. package/dist/http-server.d.ts +0 -1
  91. package/dist/http-server.js +1 -1
  92. package/dist/hunt/hunt-report.d.ts +0 -2
  93. package/dist/hunt/hunt-report.js +1 -1
  94. package/dist/impact/impact-store.d.ts +0 -5
  95. package/dist/impact/impact-store.js +1 -1
  96. package/dist/index.d.ts +4 -1
  97. package/dist/init/agent-rules.d.ts +17 -0
  98. package/dist/init/agent-rules.js +31 -1
  99. package/dist/init/capabilities.d.ts +0 -2
  100. package/dist/init/capabilities.js +1 -1
  101. package/dist/init/closing-hint.d.ts +36 -0
  102. package/dist/init/closing-hint.js +120 -0
  103. package/dist/init/confirm.d.ts +0 -25
  104. package/dist/init/confirm.js +3 -3
  105. package/dist/init/cra.d.ts +14 -1
  106. package/dist/init/cra.js +43 -12
  107. package/dist/init/dev-script.d.ts +48 -0
  108. package/dist/init/dev-script.js +63 -0
  109. package/dist/init/mcp.d.ts +16 -5
  110. package/dist/init/mcp.js +26 -10
  111. package/dist/init/plan-framework.d.ts +0 -5
  112. package/dist/init/plan-framework.js +83 -9
  113. package/dist/init/plan.d.ts +10 -30
  114. package/dist/init/plan.js +30 -15
  115. package/dist/init/refresh-rules.d.ts +39 -0
  116. package/dist/init/refresh-rules.js +51 -0
  117. package/dist/init/run.d.ts +0 -26
  118. package/dist/init/run.js +14 -87
  119. package/dist/init/snippets.js +53 -10
  120. package/dist/input/real-input.d.ts +2 -0
  121. package/dist/input/real-input.js +3 -2
  122. package/dist/input/scroll-find.d.ts +7 -4
  123. package/dist/input/scroll-find.js +63 -25
  124. package/dist/intent/inline-intent.d.ts +46 -0
  125. package/dist/intent/inline-intent.js +103 -0
  126. package/dist/intent/intent-store.d.ts +37 -0
  127. package/dist/intent/intent-store.js +112 -0
  128. package/dist/intent/intent-tools.d.ts +2 -0
  129. package/dist/intent/intent-tools.js +80 -0
  130. package/dist/intent/open-intents.d.ts +21 -0
  131. package/dist/intent/open-intents.js +25 -0
  132. package/dist/journal/envelope.d.ts +26 -26
  133. package/dist/journal/journal-recorder.d.ts +23 -0
  134. package/dist/journal/journal-recorder.js +35 -0
  135. package/dist/mcp/attach-memory.d.ts +67 -0
  136. package/dist/mcp/attach-memory.js +142 -0
  137. package/dist/mcp/mcp-proxy.d.ts +0 -7
  138. package/dist/mcp/mcp-proxy.js +14 -2
  139. package/dist/mcp/mcp.d.ts +2 -0
  140. package/dist/mcp/mcp.js +45 -23
  141. package/dist/mcp/server-instructions.js +45 -3
  142. package/dist/mcp/tool-catalog-cache.d.ts +8 -0
  143. package/dist/mcp/tool-catalog-cache.js +10 -0
  144. package/dist/oracles/flow-instrument-gaps.d.ts +4 -4
  145. package/dist/oracles/flow-instrument-gaps.js +4 -5
  146. package/dist/oracles/self-instrument.d.ts +9 -9
  147. package/dist/oracles/self-instrument.js +19 -13
  148. package/dist/pool/browser-pool.d.ts +12 -1
  149. package/dist/pool/browser-pool.js +31 -1
  150. package/dist/pool/playwright-launcher.js +3 -1
  151. package/dist/project/fs-port.d.ts +3 -0
  152. package/dist/project/fs-port.js +3 -2
  153. package/dist/project/memory-fs.js +6 -1
  154. package/dist/project/project-tools.js +3 -32
  155. package/dist/project/reticle-dir.d.ts +2 -0
  156. package/dist/project/reticle-dir.js +1 -0
  157. package/dist/runs/context-tools.d.ts +2 -0
  158. package/dist/runs/context-tools.js +89 -0
  159. package/dist/runs/run-context.d.ts +40 -0
  160. package/dist/runs/run-context.js +158 -0
  161. package/dist/runs/run-diff.d.ts +2 -1
  162. package/dist/runs/verification-sync.js +2 -2
  163. package/dist/session/dev-server-probe.d.ts +0 -8
  164. package/dist/session/dev-server-probe.js +1 -1
  165. package/dist/session/last-act.d.ts +7 -1
  166. package/dist/session/last-act.js +2 -2
  167. package/dist/session/live-control-tools.js +1 -42
  168. package/dist/session/no-session-diagnosis.d.ts +10 -0
  169. package/dist/session/no-session-diagnosis.js +24 -7
  170. package/dist/session/no-session-next-action.d.ts +5 -0
  171. package/dist/session/no-session-next-action.js +35 -3
  172. package/dist/session/no-session-watch.d.ts +8 -0
  173. package/dist/session/no-session-watch.js +82 -64
  174. package/dist/session/output-budget.js +4 -1
  175. package/dist/session/session-health.d.ts +9 -0
  176. package/dist/session/session-health.js +23 -0
  177. package/dist/session/session-manager.js +50 -1
  178. package/dist/session/session-successor.d.ts +37 -0
  179. package/dist/session/session-successor.js +78 -0
  180. package/dist/session/session.d.ts +31 -1
  181. package/dist/session/session.js +75 -2
  182. package/dist/telemetry/automation-hint.d.ts +2 -1
  183. package/dist/telemetry/feedback-outbox.d.ts +2 -1
  184. package/dist/telemetry/project-profile.d.ts +0 -8
  185. package/dist/telemetry/project-profile.js +1 -1
  186. package/dist/telemetry/run-telemetry.d.ts +0 -2
  187. package/dist/telemetry/run-telemetry.js +1 -1
  188. package/dist/telemetry/verification-of.js +2 -2
  189. package/dist/tools/act-tools.js +126 -21
  190. package/dist/tools/assert-source.d.ts +22 -0
  191. package/dist/tools/assert-source.js +53 -0
  192. package/dist/tools/assert-verdict.d.ts +20 -3
  193. package/dist/tools/assert-verdict.js +36 -4
  194. package/dist/tools/browser-tools.js +0 -20
  195. package/dist/tools/complete-snapshot.d.ts +48 -0
  196. package/dist/tools/complete-snapshot.js +75 -0
  197. package/dist/tools/coverage-tools.d.ts +10 -1
  198. package/dist/tools/coverage-tools.js +123 -69
  199. package/dist/tools/dynamic-tools.js +29 -8
  200. package/dist/tools/error-recovery.d.ts +24 -14
  201. package/dist/tools/error-recovery.js +70 -9
  202. package/dist/tools/feedback-tools.d.ts +1 -1
  203. package/dist/tools/feedback-tools.js +2 -2
  204. package/dist/tools/invoke-tool.js +62 -2
  205. package/dist/tools/lease-hint.d.ts +10 -0
  206. package/dist/tools/lease-hint.js +53 -4
  207. package/dist/tools/lease-tools.d.ts +5 -1
  208. package/dist/tools/lease-tools.js +22 -8
  209. package/dist/tools/navigate-arrival.d.ts +0 -2
  210. package/dist/tools/navigate-arrival.js +1 -1
  211. package/dist/tools/observe-tools.js +63 -26
  212. package/dist/tools/read-tools.js +5 -3
  213. package/dist/tools/real-input-attempt.d.ts +22 -0
  214. package/dist/tools/real-input-attempt.js +153 -1
  215. package/dist/tools/reconcile-tools.js +18 -4
  216. package/dist/tools/resolve-target.d.ts +0 -7
  217. package/dist/tools/sdk-marker-probe.d.ts +29 -0
  218. package/dist/tools/sdk-marker-probe.js +57 -0
  219. package/dist/tools/snapshot-delta.d.ts +1 -1
  220. package/dist/tools/tool-kit.d.ts +12 -0
  221. package/dist/tools/tool-kit.js +16 -0
  222. package/dist/tools/tool-names.d.ts +3 -0
  223. package/dist/tools/tool-names.js +3 -0
  224. package/dist/tools/tool-surface.d.ts +85 -6
  225. package/dist/tools/tool-surface.js +122 -2
  226. package/dist/tools/tools.js +22 -4
  227. package/dist/tools/unadvertised-help.js +4 -4
  228. package/dist/tools/verify-next-baton.d.ts +53 -0
  229. package/dist/tools/verify-next-baton.js +52 -0
  230. package/dist/update/update-nudge.d.ts +7 -10
  231. package/dist/update/update-nudge.js +12 -3
  232. package/dist/update/updater.d.ts +1 -1
  233. package/docs/README.md +2 -1
  234. package/docs/cli/cloud.mdx +1 -1
  235. package/docs/cli/doctor.mdx +1 -0
  236. package/docs/cli/verify.mdx +4 -5
  237. package/docs/desktop.mdx +1 -1
  238. package/docs/docs.json +2 -2
  239. package/docs/gates.md +1 -1
  240. package/docs/install-manual.mdx +1 -1
  241. package/docs/packages/browser.mdx +2 -2
  242. package/docs/packages/server.mdx +1 -1
  243. package/docs/packages.mdx +8 -6
  244. package/docs/platform-integration.md +17 -0
  245. package/docs/predicates.mdx +8 -0
  246. package/docs/reticle-manual.md +106 -0
  247. package/docs/tools-tools-and-run.mdx +2 -4
  248. package/docs/troubleshooting.mdx +9 -0
  249. package/docs/usage.md +3 -2
  250. package/package.json +2 -2
  251. package/dist/session/session-readiness.d.ts +0 -34
  252. package/dist/session/session-readiness.js +0 -36
@@ -0,0 +1,75 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * Finish a truncated snapshot, or say it could not be finished.
4
+ *
5
+ * The snapshot walk stops at its node cap and returns a document-order PREFIX with `truncated:true`.
6
+ * That flag has always been honest and has never been actionable: it says the read stopped, not
7
+ * where, so a caller could receive half a page and draw a NEGATIVE conclusion from it — "no error is
8
+ * shown", "the row is gone" — when the thing was simply past the cut. A read that under-reports is a
9
+ * verification result that is quietly wrong.
10
+ *
11
+ * The walk now names its own frontier (`unread`: refs of the subtrees it never entered), so the cut
12
+ * is recoverable: re-read each branch at its own path and the union is the whole tree. This is that
13
+ * loop, plus the half that matters — when the union CANNOT be assembled it says so, and the caller
14
+ * must not turn an empty result over it into an absence.
15
+ *
16
+ * The assembled tree is a UNION of subtrees, not a re-render: each appended chunk is indented from
17
+ * its own root. Callers scan it line by line (that is what every consumer of `tree` does), so the
18
+ * indentation restart costs nothing and re-walking the whole page from scratch would cost a lot.
19
+ */
20
+ /**
21
+ * How many snapshot round-trips one completion may spend.
22
+ *
23
+ * Each is a real command to the page, so this is the cost ceiling of a completed read, not a safety
24
+ * net. Twelve covers a page whose cut frontier is a handful of branches a couple of levels deep —
25
+ * the shape a node cap actually produces — while keeping the worst case near a dozen round-trips
26
+ * rather than a walk of the DOM one subtree at a time.
27
+ */
28
+ export const MAX_COMPLETION_READS = 12;
29
+ /**
30
+ * How many times the completion may follow a frontier into a new one.
31
+ *
32
+ * A branch re-read can itself truncate and name further branches, so this recursion needs a floor
33
+ * under it independent of the read budget: a hostile or pathologically deep DOM can hand back a new
34
+ * frontier every round forever. Four rounds (the first read plus three levels of re-reads) is deeper
35
+ * than a node cap reaches on a real page, and terminates on one that is not real.
36
+ */
37
+ export const MAX_COMPLETION_DEPTH = 4;
38
+ const SnapshotShape = z.object({
39
+ tree: z.string().optional(),
40
+ truncated: z.boolean().optional(),
41
+ unread: z.array(z.string()).optional(),
42
+ unreadOverflow: z.literal(true).optional(),
43
+ });
44
+ export async function readCompleteTree(read) {
45
+ const parts = [];
46
+ let frontier = [undefined];
47
+ let reads = 0;
48
+ const stop = (because) => ({
49
+ tree: parts.join('\n'),
50
+ complete: false,
51
+ reads,
52
+ incompleteBecause: because,
53
+ });
54
+ for (let round = 0; round < MAX_COMPLETION_DEPTH; round += 1) {
55
+ const next = [];
56
+ for (const scope of frontier) {
57
+ if (reads >= MAX_COMPLETION_READS) {
58
+ return stop(`the page kept naming unread branches past ${String(MAX_COMPLETION_READS)} reads, so part of it was never seen`);
59
+ }
60
+ const parsed = SnapshotShape.safeParse(await read(scope));
61
+ reads += 1;
62
+ if (!parsed.success)
63
+ return stop('a snapshot came back in a shape this read cannot trust');
64
+ parts.push(parsed.data.tree ?? '');
65
+ if (true === parsed.data.unreadOverflow) {
66
+ return stop('the cut named more branches than one snapshot can carry, so the frontier itself is incomplete and no sequence of re-reads can finish this page');
67
+ }
68
+ next.push(...(parsed.data.unread ?? []));
69
+ }
70
+ if (0 === next.length)
71
+ return { tree: parts.join('\n'), complete: true, reads };
72
+ frontier = next;
73
+ }
74
+ return stop(`re-reads kept cutting after ${String(MAX_COMPLETION_DEPTH)} rounds, so part of the page was never seen`);
75
+ }
@@ -5,5 +5,14 @@ interface Control {
5
5
  }
6
6
  /** Parse the interactive snapshot into {ref,label} controls, preserving document order. */
7
7
  export declare function parseControls(tree: string): Control[];
8
- export declare const COVERAGE_TOOLS: ToolDef[];
8
+ /**
9
+ * Build the coverage tool over the dispatchable tool table.
10
+ *
11
+ * The table arrives as a thunk rather than an import: `tools.ts` assembles it FROM this module, so
12
+ * importing it back would close a cycle whose failure mode is a load-order-dependent crash rather
13
+ * than an error anyone sees while writing it. The thunk is called inside the handler, long after the
14
+ * table exists. It carries the merged names — the ones `reticle_run` will actually accept — because
15
+ * a tool that merged into a parent is not something an agent can call or a maintainer can cut.
16
+ */
17
+ export declare function buildCoverageTools(toolNames: () => readonly string[]): ToolDef[];
9
18
  export {};
@@ -1,5 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  import { coverageRegressed, observabilityOf } from '../honesty/observability.js';
3
+ import { foldFeatureCapture } from '../honesty/feature-capture.js';
4
+ import { foldToolHitRate } from '../honesty/tool-hit-rate.js';
5
+ import { allSessionIntents } from '../intent/open-intents.js';
3
6
  import { ReticleCommand, SnapshotMode } from '@reticlehq/core';
4
7
  import { ReticleTool } from './tool-names.js';
5
8
  import { asString } from './tools-helpers.js';
@@ -49,73 +52,124 @@ export function parseControls(tree) {
49
52
  }
50
53
  return controls;
51
54
  }
52
- export const COVERAGE_TOOLS = [
53
- {
54
- name: ReticleTool.COVERAGE,
55
- description: 'Which interactive controls you have driven this session, and which you have NOT. Returns { total, exercised, untouched:[{ref,label}], alsoDroveGone? } over the controls currently on the page. Use it to decide whether verification is finished: an untouched list that still holds the controls your change affects means you are not done. This is about what you did not TOUCH — distinct from the `coverage` field on an action result, which reports what the layer could not SEE.',
56
- example: {},
57
- inputSchema: { ...sessionIdShape },
58
- outputSchema: {
59
- total: z.number(),
60
- exercised: z.number(),
61
- untouched: z.array(z.object({ ref: z.string(), label: z.string() })),
62
- alsoDroveGone: z
63
- .number()
64
- .optional()
65
- .describe('Controls you drove that are no longer on the page — usually because the action SUCCEEDED and removed them (archive/delete/submit/navigate). Counted separately so `exercised: 0` never appears immediately after real work.'),
66
- instrumentationGaps: z
67
- .array(z.unknown())
68
- .optional()
69
- .describe('What this app still cannot tell Reticle, as of your most recent verdict — each entry is { kind, missing, cost, fix, source?, ref? }. These are not controls you skipped; they are checks this app CANNOT answer until it is instrumented, so driving the untouched list will not close them. Apply each `fix` and re-verify: the gap disappears from this list when the app can answer, and every later verdict on this app gets stronger. OMITTED when nothing is missing.'),
70
- unproven: z
71
- .boolean()
72
- .optional()
73
- .describe('True when verification is NOT finished for a reason driving more controls cannot fix — instrumentationGaps is non-empty. Present only when true, so its absence is not a claim.'),
74
- observability: z
75
- .object({ driven: z.number(), observable: z.number(), percent: z.number().optional() })
76
- .optional()
77
- .describe('Of the controls you DROVE, how many Reticle could fully observe. `untouched` above is work left for you; this is work left in the APP, and driving more controls does not move it. `percent` is OMITTED when nothing was driven, because 0/0 is not 100%.'),
78
- observabilityRegressed: z
79
- .object({ was: z.number(), now: z.number() })
80
- .optional()
81
- .describe('This project has previously reached a HIGHER observability than this run did. Usually means an assertion or an instrumented path was removed — the cheapest way to stop a gap firing is to stop asserting the thing that revealed it. Present only when a drop is real and the run was large enough to compare.'),
82
- },
83
- handler: async (deps, args) => {
84
- const sessionId = asString(args['sessionId']);
85
- const session = deps.sessions.resolve(sessionId);
86
- // INTERACTIVE mode is already the "controls only" view, so the denominator is the page's own
87
- // notion of what can be driven rather than a second, drifting definition maintained here.
88
- const result = await commandOrThrow(deps, sessionId, ReticleCommand.SNAPSHOT, {
89
- mode: SnapshotMode.INTERACTIVE,
90
- });
91
- const tree = asString(result['tree']) ?? '';
92
- // Matched by ref AND by label — see coverage-identity. A ref dies with the next re-render, so
93
- // on a framework that replaces nodes this reported `exercised: 0` however much work was done.
94
- // `droveGone` keeps the other honesty: archive/delete/submit remove their own control, so a
95
- // drive that WORKED must not read as no coverage at all.
96
- const { exercised, droveGone, untouched } = exercisedCount(parseControls(tree), session.actedRefs(), session.actedLabels());
97
- // The other half of "am I done?". `untouched` answers what you did not DRIVE; this answers
98
- // what this app cannot ANSWER, which no amount of further driving will change. Reporting the
99
- // first without the second is how an agent finishes a pass believing it verified something the
100
- // app was never able to confirm.
101
- const gaps = session.gaps?.open() ?? [];
102
- // The number, and the floor under it, together. A coverage figure that can only ever be
103
- // reported and never contradicted is one an agent learns to satisfy rather than to earn.
104
- const observability = observabilityOf(session.actedRefs(), gaps);
105
- const best = await deps.project.bestObservability();
106
- const regressed = coverageRegressed(best, observability);
107
- if (observability.percent !== undefined) {
108
- await deps.project.raiseObservability(observability.percent);
109
- }
110
- return {
111
- total: parseControls(tree).length,
112
- exercised,
113
- untouched,
114
- ...(droveGone > 0 ? { alsoDroveGone: droveGone } : {}),
115
- ...(gaps.length > 0 ? { instrumentationGaps: gaps, unproven: true } : {}),
116
- observability,
117
- ...(regressed === undefined ? {} : { observabilityRegressed: regressed }),
118
- };
55
+ /**
56
+ * Build the coverage tool over the dispatchable tool table.
57
+ *
58
+ * The table arrives as a thunk rather than an import: `tools.ts` assembles it FROM this module, so
59
+ * importing it back would close a cycle whose failure mode is a load-order-dependent crash rather
60
+ * than an error anyone sees while writing it. The thunk is called inside the handler, long after the
61
+ * table exists. It carries the merged names — the ones `reticle_run` will actually accept — because
62
+ * a tool that merged into a parent is not something an agent can call or a maintainer can cut.
63
+ */
64
+ export function buildCoverageTools(toolNames) {
65
+ return [
66
+ {
67
+ name: ReticleTool.COVERAGE,
68
+ description: 'Which interactive controls you have driven this session, and which you have NOT. Returns { total, exercised, untouched:[{ref,label}], alsoDroveGone? } over the controls currently on the page. Use it to decide whether verification is finished: an untouched list that still holds the controls your change affects means you are not done. This is about what you did not TOUCH — distinct from the `coverage` field on an action result, which reports what the layer could not SEE.',
69
+ example: {},
70
+ inputSchema: { ...sessionIdShape },
71
+ outputSchema: {
72
+ total: z.number(),
73
+ exercised: z.number(),
74
+ untouched: z.array(z.object({ ref: z.string(), label: z.string() })),
75
+ alsoDroveGone: z
76
+ .number()
77
+ .optional()
78
+ .describe('Controls you drove that are no longer on the page — usually because the action SUCCEEDED and removed them (archive/delete/submit/navigate). Counted separately so `exercised: 0` never appears immediately after real work.'),
79
+ instrumentationGaps: z
80
+ .array(z.unknown())
81
+ .optional()
82
+ .describe('What this app still cannot tell Reticle, as of your most recent verdict — each entry is { kind, missing, cost, fix, source?, ref? }. These are not controls you skipped; they are checks this app CANNOT answer until it is instrumented, so driving the untouched list will not close them. Apply each `fix` and re-verify: the gap disappears from this list when the app can answer, and every later verdict on this app gets stronger. OMITTED when nothing is missing.'),
83
+ unproven: z
84
+ .boolean()
85
+ .optional()
86
+ .describe('True when verification is NOT finished for a reason driving more controls cannot fix — instrumentationGaps is non-empty. Present only when true, so its absence is not a claim.'),
87
+ observability: z
88
+ .object({ driven: z.number(), observable: z.number(), percent: z.number().optional() })
89
+ .optional()
90
+ .describe('Of the controls you DROVE, how many Reticle could fully observe. `untouched` above is work left for you; this is work left in the APP, and driving more controls does not move it. `percent` is OMITTED when nothing was driven, because 0/0 is not 100%.'),
91
+ featureUse: z
92
+ .object({
93
+ observed: z.boolean(),
94
+ truncated: z.boolean().optional(),
95
+ context: z.unknown().optional(),
96
+ intents: z.unknown().optional(),
97
+ missed: z.unknown().optional(),
98
+ })
99
+ .optional()
100
+ .describe('Whether this session used `reticle_context` and `reticle_intent` at all, and what it cost when it did not: `context` counts the calls and what followed each one, `intents` the ledger, `missed` the verdicts drawn against an empty ledger and the reads that re-fetched a fact already established. `observed:false` means nothing was recorded for this session — NOT that nothing was used. An instrument for deciding whether those two features earn their place; nothing here is a fault of yours.'),
101
+ toolHitRate: z
102
+ .object({
103
+ observed: z.boolean(),
104
+ cells: z.unknown().optional(),
105
+ called: z.unknown().optional(),
106
+ neverCalled: z.unknown().optional(),
107
+ })
108
+ .optional()
109
+ .describe('Which Reticle tools THIS session called, and how reachable each one was: `called` lists every tool with its call count, `neverCalled` the rest of the table, and both carry a `tier` — `default` (advertised to every agent), `extended` (advertised only on the full surface) or `run-only` (reachable solely by naming it to reticle_run). `cells` counts each tier on both sides. `observed:false` means nothing was recorded for this session, NOT that nothing was called. THREE LIMITS, all load-bearing: this is ONE session, so it is evidence about this run and is not a hit rate — a hit rate is a distribution over many sessions. A never-called tool is not a useless tool; one used once a month in an emergency looks identical here to one nobody has ever called, so frequency alone cannot decide a cut. And the counts describe how this run went, never how it should have gone — nothing here is a fault of yours.'),
110
+ observabilityRegressed: z
111
+ .object({ was: z.number(), now: z.number() })
112
+ .optional()
113
+ .describe('This project has previously reached a HIGHER observability than this run did. Usually means an assertion or an instrumented path was removed — the cheapest way to stop a gap firing is to stop asserting the thing that revealed it. Present only when a drop is real and the run was large enough to compare.'),
114
+ },
115
+ handler: async (deps, args) => {
116
+ const sessionId = asString(args['sessionId']);
117
+ const session = deps.sessions.resolve(sessionId);
118
+ // INTERACTIVE mode is already the "controls only" view, so the denominator is the page's own
119
+ // notion of what can be driven rather than a second, drifting definition maintained here.
120
+ const result = await commandOrThrow(deps, sessionId, ReticleCommand.SNAPSHOT, {
121
+ mode: SnapshotMode.INTERACTIVE,
122
+ });
123
+ const tree = asString(result['tree']) ?? '';
124
+ // Matched by ref AND by label — see coverage-identity. A ref dies with the next re-render, so
125
+ // on a framework that replaces nodes this reported `exercised: 0` however much work was done.
126
+ // `droveGone` keeps the other honesty: archive/delete/submit remove their own control, so a
127
+ // drive that WORKED must not read as no coverage at all.
128
+ const { exercised, droveGone, untouched } = exercisedCount(parseControls(tree), session.actedRefs(), session.actedLabels());
129
+ // The other half of "am I done?". `untouched` answers what you did not DRIVE; this answers
130
+ // what this app cannot ANSWER, which no amount of further driving will change. Reporting the
131
+ // first without the second is how an agent finishes a pass believing it verified something the
132
+ // app was never able to confirm.
133
+ const gaps = session.gaps?.open() ?? [];
134
+ // Folded here rather than given a tool of its own: this is already the "am I done, and what is
135
+ // this verdict worth" read, and a number nobody has a reason to ask for separately is one
136
+ // nobody ever sees. It is a fold over the journal and the intent ledger plus the one thing
137
+ // neither records — see honesty/feature-capture.ts.
138
+ const featureUse = foldFeatureCapture({
139
+ calls: session.capture.calls(),
140
+ dropped: session.capture.dropped,
141
+ actions: await session.readJournalActions(),
142
+ intents: await allSessionIntents(deps, sessionId),
143
+ finalActions: session.actionCount,
144
+ });
145
+ // Same fold, same reason, one level out: which of the whole tool table this session reached
146
+ // for, crossed with how reachable each name was. See honesty/tool-hit-rate.ts for what it can
147
+ // and cannot establish — the limits matter more than usual, because this is the number a
148
+ // decision to DELETE tools would be made on.
149
+ const toolHitRate = foldToolHitRate({
150
+ calls: session.capture?.toolCalls(),
151
+ allTools: toolNames(),
152
+ });
153
+ // The number, and the floor under it, together. A coverage figure that can only ever be
154
+ // reported and never contradicted is one an agent learns to satisfy rather than to earn.
155
+ const observability = observabilityOf(session.actedRefs(), gaps);
156
+ const best = await deps.project.bestObservability();
157
+ const regressed = coverageRegressed(best, observability);
158
+ if (observability.percent !== undefined) {
159
+ await deps.project.raiseObservability(observability.percent);
160
+ }
161
+ return {
162
+ total: parseControls(tree).length,
163
+ exercised,
164
+ untouched,
165
+ ...(droveGone > 0 ? { alsoDroveGone: droveGone } : {}),
166
+ ...(gaps.length > 0 ? { instrumentationGaps: gaps, unproven: true } : {}),
167
+ observability,
168
+ ...(regressed === undefined ? {} : { observabilityRegressed: regressed }),
169
+ featureUse,
170
+ toolHitRate,
171
+ };
172
+ },
119
173
  },
120
- },
121
- ];
174
+ ];
175
+ }
@@ -3,8 +3,9 @@ import { runTool } from './invoke-tool.js';
3
3
  import { buildErrorPayload } from './error-recovery.js';
4
4
  import { mergedNameRedirect, mergedNameMessage } from './merged-name-redirect.js';
5
5
  import { ReticleTool } from './tool-names.js';
6
- import { TOOL_PROFILE_ENV } from './tool-surface.js';
6
+ import { ADVERTISE_ALL_ENV } from './tool-surface.js';
7
7
  import { getSessionMetrics } from '../telemetry/session-metrics.js';
8
+ import { isPredicateParam, predicateGrammar } from '../events/predicate-eval.js';
8
9
  /**
9
10
  * On-demand tool loading for MCP — the answer to the per-turn tool-definition tax.
10
11
  *
@@ -47,6 +48,8 @@ function paramInfo(shape) {
47
48
  function unknownParamsError(toolName, unknown) {
48
49
  return `unknown ${1 === unknown.length ? 'parameter' : 'parameters'} for ${toolName}: ${unknown.join(', ')} — NOT applied, so any result would be an answer to a different question`;
49
50
  }
51
+ /** What to write instead — the one parameter reticle_tools declares, and the no-argument form. */
52
+ const TOOLS_ARG_HINT = 'name the tools you want in `names`: reticle_tools { names: ["reticle_act_and_wait"] } — or call it with no arguments for the full catalog';
50
53
  /** The keys not declared by `shape`, in call order. */
51
54
  function unknownKeys(args, shape) {
52
55
  const declared = new Set(Object.keys(shape));
@@ -70,19 +73,32 @@ export function buildDynamicTools(allTools, profile) {
70
73
  // advertises the whole registry any more, so BOTH meta-tools are on every surface and
71
74
  // reticle_run is always the way to the tail. The old wording would now send an agent
72
75
  // away from the only tool that can reach half the registry.
73
- note: `The surface is read once at daemon startup: change ${TOOL_PROFILE_ENV} and restart the daemon, or it has no effect. No surface advertises every tool — the advertised count is capped because editors budget tools across all connected MCP servers. Every tool listed here is callable through reticle_run { tool, args } whether or not it is advertised.`,
76
+ note: `The surface is read once at daemon startup: set ${ADVERTISE_ALL_ENV}=1 and restart the daemon, or it has no effect. No surface advertises every tool: the advertised count is capped because editors budget tools across all connected MCP servers. Every tool listed here is callable through reticle_run { tool, args } whether or not it is advertised.`,
74
77
  },
75
78
  };
79
+ const toolsShape = {
80
+ names: z
81
+ .array(z.string())
82
+ .optional()
83
+ .describe('Tool names to load full params for. Omit to list all tools with summaries.'),
84
+ };
76
85
  const reticleTools = {
77
86
  name: ReticleTool.TOOLS,
78
87
  description: 'Discover Reticle tools on demand. Call with no arguments to list every tool (name + one-line summary); call with names:["reticle_network", …] to load full descriptions and parameters for specific tools. Then invoke them with reticle_run. This avoids paying for every tool definition on every turn. To make a verification REUSABLE (record once, replay free forever), the flow workflow lives here: reticle_record{action:"start"} → act → reticle_flow_save → reticle_verify{action:"flows"} (and reticle_flow_heal on drift). Load those names when you want to save or re-run a flow.',
79
- inputSchema: {
80
- names: z
81
- .array(z.string())
82
- .optional()
83
- .describe('Tool names to load full params for. Omit to list all tools with summaries.'),
84
- },
88
+ inputSchema: toolsShape,
85
89
  handler: (_deps, args) => {
90
+ // The discovery tool declared one parameter and checked none, so a call that misnamed it had
91
+ // the key dropped and came back with the WHOLE catalogue — a well-formed answer to a question
92
+ // nobody asked, and one the caller cannot tell from the answer it wanted. Same check and same
93
+ // wording as reticle_run's, because two spellings of one refusal is how one of them rots.
94
+ const stray = unknownKeys(args, toolsShape);
95
+ if (stray.length > 0) {
96
+ return Promise.resolve({
97
+ error: unknownParamsError(ReticleTool.TOOLS, stray),
98
+ params: paramInfo(toolsShape),
99
+ hint: TOOLS_ARG_HINT,
100
+ });
101
+ }
86
102
  const names = Array.isArray(args['names'])
87
103
  ? args['names'].filter((n) => 'string' === typeof n)
88
104
  : undefined;
@@ -101,6 +117,10 @@ export function buildDynamicTools(allTools, profile) {
101
117
  next: `All ${catalog.length} tools above are callable, advertised or not. Load full params with reticle_tools { names:[…] }, then call reticle_run { tool, args }.`,
102
118
  });
103
119
  }
120
+ // The grammar rides ONLY on a `names:[…]` reply that asked for a tool taking a predicate, so
121
+ // no turn pays for it in the catalog or in the advertised schema. The lean tool surface says
122
+ // "call reticle_tools for the full field grammar of a kind"; this is where that lands.
123
+ const carriesPredicate = names.some((n) => Object.values(byName.get(n)?.inputSchema ?? {}).some((schema) => isPredicateParam(schema)));
104
124
  return Promise.resolve({
105
125
  tools: names.map((n) => {
106
126
  const t = byName.get(n);
@@ -108,6 +128,7 @@ export function buildDynamicTools(allTools, profile) {
108
128
  ? { name: n, error: 'unknown tool' }
109
129
  : { name: n, description: t.description, params: paramInfo(t.inputSchema) };
110
130
  }),
131
+ ...(carriesPredicate ? { predicateGrammar: predicateGrammar() } : {}),
111
132
  });
112
133
  },
113
134
  };
@@ -18,11 +18,35 @@ export declare const RECOVERY: {
18
18
  readonly MISSING_BASELINE: string;
19
19
  readonly MISSING_RECORDING: string;
20
20
  readonly STALE_REF: string;
21
+ readonly STALE_REF_AFTER_EDIT: string;
21
22
  readonly INVALID_NAME: string;
23
+ /**
24
+ * The call was well-formed and the SELECTOR missed. Nothing about the arguments was wrong, so the
25
+ * BAD_ARGUMENTS answer ("re-read that tool's parameters") sends the reader to the one place that
26
+ * holds no answer — measured live on a `target: { testid }` that simply was not on the page yet.
27
+ *
28
+ * The error text already names the two moves that work, so this adds the fact the text cannot
29
+ * carry: which of them to reach for, and that nothing was acted on.
30
+ */
31
+ readonly TARGET_MISSED: string;
22
32
  readonly NO_SUCH_OPTION: string;
23
33
  readonly BAD_ARGUMENTS: string;
24
34
  readonly WRONG_TARGET: string;
25
35
  readonly NOT_EDITABLE: string;
36
+ /**
37
+ * Written as the ARGUMENT OBJECT the caller has to send, not as a dotted path.
38
+ *
39
+ * Measured on a benchmark run that spent its entire budget here: the refusal said "retry with
40
+ * args.confirmDangerous=true", and the agent went looking for a parameter by that name, could not
41
+ * find one at the top level of the schema, and re-read the tool list rather than retrying. The
42
+ * flag IS documented — inside the `args` object's own description — so a dotted path pointing at
43
+ * something that is not a top-level parameter is the whole distance between a one-line retry and
44
+ * a lost budget.
45
+ *
46
+ * The classifier is also wider than "destructive": it matches `deploy` and `publish`, so an
47
+ * ordinary "New deploy" button trips it. Naming a real trigger word here keeps that from reading
48
+ * as a malfunction on an app where nothing is being deleted.
49
+ */
26
50
  readonly CONFIRM_DANGEROUS: string;
27
51
  readonly UNSUPPORTED_SURFACE: string;
28
52
  readonly TOKEN_REQUIRED: string;
@@ -50,20 +74,6 @@ export declare function recoveryFor(message: string): string | undefined;
50
74
  * this table rather than about anybody's app.
51
75
  */
52
76
  export declare function refusalReasonFor(rawMessage: string): RefusalReason;
53
- /**
54
- * A serialized zod issue array, rendered as a sentence. Anything else is returned untouched.
55
- *
56
- * This is the one boundary every agent-visible tool failure crosses, which is why the rewrite lives
57
- * here rather than in each tool. `parsePredicate` solved this for the three predicate tools by never
58
- * producing the array; it cannot help anywhere else, because the MCP SDK validates a tool's schema
59
- * BEFORE our handler runs. So `reticle_session` with no action — the commonest mistake there is —
60
- * never reaches the friendly "unknown action … expected: [tune, yield, …]" its handler already
61
- * builds, and the agent gets the raw array instead. Driven live against the shipped daemon.
62
- *
63
- * Conservative by construction: it only rewrites a JSON ARRAY whose every element carries both
64
- * `code` and `message`. A tool that legitimately returns JSON is left alone.
65
- */
66
- export declare function zodArrayAsSentence(message: string): string;
67
77
  /**
68
78
  * The ask attached to errors we do NOT recognize. An unrecognized error is, by definition, the case
69
79
  * where Reticle failed in a way nobody anticipated — the single highest-value moment to hear from the
@@ -66,7 +66,9 @@ export const RECOVERY = {
66
66
  'target the one you mean.',
67
67
  UNKNOWN_SESSION: 'That sessionId is not connected. Call reticle_sessions for the current ids and retry with a valid one.',
68
68
  THROTTLED: 'The target tab is backgrounded/throttled, so actions may silently no-op. Ask the human to bring ' +
69
- 'the tab to the front, or run `reticle drive <url>` for a guaranteed scriptable context.',
69
+ 'the tab to the front, or acquire a guaranteed scriptable context yourself with ' +
70
+ '`reticle_run { tool: "reticle_lease", action: "acquire", url }` (a human can equivalently run ' +
71
+ '`reticle drive <url>`).',
70
72
  MISSING_BASELINE: 'That baseline does not exist yet. Call reticle_baseline { action: "list" } to see saved names, or ' +
71
73
  'reticle_baseline { action: "save", name } to capture one before diffing against it.',
72
74
  MISSING_RECORDING:
@@ -79,11 +81,28 @@ export const RECOVERY = {
79
81
  'before it. Reticle refuses here rather than clicking whatever now occupies that slot. Call ' +
80
82
  'reticle_query again for a fresh ref and retry the action — and prefer reticle_act_and_wait ' +
81
83
  '{ until } when an action changes the page, so the next ref is taken after it settles.',
84
+ STALE_REF_AFTER_EDIT: 'That ref went stale because YOUR OWN EDIT landed: the dev server hot-updated the module named ' +
85
+ 'in the message above and the framework re-rendered, so the node the ref pointed at was ' +
86
+ 'replaced. Nothing is wrong with the app and nothing is wrong with Reticle — this is the ' +
87
+ 'build-and-verify loop working. Call reticle_query for a fresh ref and drive again, and treat ' +
88
+ 'anything you observed before the update as describing the previous version of the code.',
82
89
  INVALID_NAME: 'That name is not a usable one. A flow, baseline or recording name must be a single safe path ' +
83
90
  'segment — letters, digits, dash and underscore only, starting with a letter or digit, at most ' +
84
91
  '64 characters. No slashes, no "..", no leading dot: the name becomes a filename under ' +
85
92
  '.reticle/, so anything that could escape that directory is refused. Retry with a slug like ' +
86
93
  '"checkout-happy-path". This is an invalid call, not a Reticle defect: there is nothing to report.',
94
+ /**
95
+ * The call was well-formed and the SELECTOR missed. Nothing about the arguments was wrong, so the
96
+ * BAD_ARGUMENTS answer ("re-read that tool's parameters") sends the reader to the one place that
97
+ * holds no answer — measured live on a `target: { testid }` that simply was not on the page yet.
98
+ *
99
+ * The error text already names the two moves that work, so this adds the fact the text cannot
100
+ * carry: which of them to reach for, and that nothing was acted on.
101
+ */
102
+ TARGET_MISSED: 'The call was valid — the selector matched nothing on the page RIGHT NOW, and nothing was ' +
103
+ 'acted on. Take a reticle_snapshot to see what is actually there (a view may still be ' +
104
+ 'rendering, or the control may be named differently), then retry with what it shows. This is ' +
105
+ 'a miss, not a Reticle defect: there is nothing to report.',
87
106
  NO_SUCH_OPTION: 'That <select> has no option with the value you asked for, and the message above lists the ones ' +
88
107
  'it does have. Reticle refuses rather than assigning it: an unmatched value deselects everything, ' +
89
108
  'and the change event that follows has been observed persisting an empty setting into the app. ' +
@@ -102,9 +121,24 @@ export const RECOVERY = {
102
121
  'telling you something: drive whatever ENABLES the field (open the editor, satisfy the ' +
103
122
  'precondition) and act again. If the field should have been editable, that is a bug in the app, ' +
104
123
  'not in Reticle.',
105
- CONFIRM_DANGEROUS: 'Reticle blocked a potentially destructive control (delete, remove, revoke…) on purpose. If you ' +
106
- 'mean it, retry the same action with args.confirmDangerous set to true. This is a deliberate ' +
107
- 'refusal, not a defect: there is nothing to report.',
124
+ /**
125
+ * Written as the ARGUMENT OBJECT the caller has to send, not as a dotted path.
126
+ *
127
+ * Measured on a benchmark run that spent its entire budget here: the refusal said "retry with
128
+ * args.confirmDangerous=true", and the agent went looking for a parameter by that name, could not
129
+ * find one at the top level of the schema, and re-read the tool list rather than retrying. The
130
+ * flag IS documented — inside the `args` object's own description — so a dotted path pointing at
131
+ * something that is not a top-level parameter is the whole distance between a one-line retry and
132
+ * a lost budget.
133
+ *
134
+ * The classifier is also wider than "destructive": it matches `deploy` and `publish`, so an
135
+ * ordinary "New deploy" button trips it. Naming a real trigger word here keeps that from reading
136
+ * as a malfunction on an app where nothing is being deleted.
137
+ */
138
+ CONFIRM_DANGEROUS: 'Reticle blocked a control whose label reads as consequential (delete, remove, revoke, deploy, ' +
139
+ 'publish, pay…) on purpose. If you mean it, send the SAME call again with ' +
140
+ '`args: { confirmDangerous: true }` — it goes inside the `args` object, not beside it. This is ' +
141
+ 'a deliberate refusal, not a defect: there is nothing to report.',
108
142
  UNSUPPORTED_SURFACE: 'That surface is not supported yet, and Reticle refuses rather than pretending — a rich-text ' +
109
143
  'editor keeps its own document model, so writing to the DOM would look right and submit the old ' +
110
144
  'content. Drive the same outcome another way (a plain input, a command, a keyboard action) or ' +
@@ -163,7 +197,9 @@ const REASON_OF = {
163
197
  MISSING_BASELINE: RefusalReason.NO_MATCH,
164
198
  MISSING_RECORDING: RefusalReason.NO_MATCH,
165
199
  STALE_REF: RefusalReason.NO_MATCH,
200
+ STALE_REF_AFTER_EDIT: RefusalReason.NO_MATCH,
166
201
  NO_SUCH_OPTION: RefusalReason.NO_MATCH,
202
+ TARGET_MISSED: RefusalReason.NO_MATCH,
167
203
  FLOW_STEP_MISSING: RefusalReason.NO_MATCH,
168
204
  UNSUPPORTED_SURFACE: RefusalReason.UNSUPPORTED,
169
205
  NOT_EDITABLE: RefusalReason.UNSUPPORTED,
@@ -200,6 +236,9 @@ const RULES = [
200
236
  },
201
237
  // The commonest post-action condition there is. Unmatched, it fell through to FEEDBACK_ASK and
202
238
  // told the agent a successful click's aftermath might be a bug in Reticle.
239
+ // Before the general rule: the edit-aware refusal contains the general one's substring too, and
240
+ // the first hit wins. "Your edit landed" is a next step; "the ref is stale" is a dead end.
241
+ { match: /the code changed underneath it/i, hint: RECOVERY.STALE_REF_AFTER_EDIT },
203
242
  { match: /no longer resolves to an element/i, hint: RECOVERY.STALE_REF },
204
243
  { match: /no <option> with value/i, hint: RECOVERY.NO_SUCH_OPTION },
205
244
  // Every guard in the browser's executeAction. These are Reticle-authored refusals of an invalid
@@ -224,15 +263,37 @@ const RULES = [
224
263
  match: /no form to submit|upload target must be|is not an HTMLElement/i,
225
264
  hint: RECOVERY.WRONG_TARGET,
226
265
  },
266
+ // BEFORE the catch-all, which would otherwise claim this one.
267
+ //
268
+ // "target matched no element ... take a reticle_snapshot" names a `reticle_*` tool in its own
269
+ // advice, so the catch-all below matched it and answered "That call did not match the tool's
270
+ // schema". The call matched the schema perfectly; the element was not on the page. Sending an
271
+ // agent to re-read arguments that were already correct costs it a turn, and this is the commonest
272
+ // refusal there is.
273
+ { match: /target matched no element/i, hint: RECOVERY.TARGET_MISSED },
227
274
  // Authored by Reticle, about the caller's arguments: the message already names the valid answers.
228
275
  { match: /^unknown action '/i, hint: RECOVERY.BAD_ARGUMENTS },
229
276
  { match: /^unsupported query strategy '/i, hint: RECOVERY.BAD_ARGUMENTS },
230
277
  { match: /^\w+ requires a string `\w+`/i, hint: RECOVERY.BAD_ARGUMENTS },
231
- // A message naming a `reticle_*` tool is one WE authored about our own API — an invalid call, not
232
- // an unanticipated failure. Left unmatched it collected the feedback ask, which pushed agents to
233
- // file reports about their own bad arguments. Kept LAST so a specific rule above always wins.
278
+ // A message naming a `reticle_*` tool or an `args.*` parameter is one WE authored about our own
279
+ // API — an invalid call, not an unanticipated failure. Left unmatched it collected the feedback
280
+ // ask, which pushed agents to file reports about their own bad arguments. Kept LAST so a specific
281
+ // rule above always wins.
282
+ //
283
+ // Keyed on the tool/parameter NAME rather than on a sentence shape. The previous form wanted
284
+ // `reticle_x { ... }` with braces AND requires/must/expected, which is one phrasing out of many;
285
+ // measured live, a drag refusal reading "pass a ref from reticle_snapshot or reticle_query as
286
+ // args.toRef" fell straight through and told the agent its own malformed call might be a Reticle
287
+ // defect worth a root-cause report. That is the FOURTH patch to this same default, after the
288
+ // executeAction guards, the destructive-action block, and two of the three spellings of
289
+ // "potentially destructive ... blocked" — each one added a phrasing instead of changing the key.
290
+ //
291
+ // A path is deliberately excluded: `@reticlehq/server/dist/...` in a stack trace is a crash we did
292
+ // NOT anticipate, and silencing the feedback ask there would lose the reports worth having. Hence
293
+ // a word boundary before `reticle_` and a required space-or-punctuation after the name, so a tool
294
+ // named in prose matches and a module path does not.
234
295
  {
235
- match: /reticle_[a-z_]+\s*\{[^}]*\}.*\b(requires|must|expected)\b/i,
296
+ match: /(?:^|[\s'"`(])reticle_[a-z_]+(?![\w/.-])|(?:^|[\s'"`(])args\.[a-z][\w]*/i,
236
297
  hint: RECOVERY.BAD_ARGUMENTS,
237
298
  },
238
299
  ];
@@ -321,7 +382,7 @@ function issueClause(issue) {
321
382
  * Conservative by construction: it only rewrites a JSON ARRAY whose every element carries both
322
383
  * `code` and `message`. A tool that legitimately returns JSON is left alone.
323
384
  */
324
- export function zodArrayAsSentence(message) {
385
+ function zodArrayAsSentence(message) {
325
386
  const text = message.trim();
326
387
  if (!text.startsWith('['))
327
388
  return message;
@@ -24,7 +24,7 @@ export declare const FEEDBACK_PROMPT: {
24
24
  * calls, assert ZERO, verification_completed 2. Agents were verifying the entire time and the metric
25
25
  * could not see it. The contract test now checks the SHAPE instead.
26
26
  */
27
- export declare const VERIFICATION_TOOLS: ReadonlySet<string>;
27
+ export declare const VERDICT_TOOLS: ReadonlySet<string>;
28
28
  /**
29
29
  * The human prompt, when one is due. Resettable for tests.
30
30
  *
@@ -173,7 +173,7 @@ export const FEEDBACK_PROMPT = {
173
173
  * calls, assert ZERO, verification_completed 2. Agents were verifying the entire time and the metric
174
174
  * could not see it. The contract test now checks the SHAPE instead.
175
175
  */
176
- export const VERIFICATION_TOOLS = new Set([
176
+ export const VERDICT_TOOLS = new Set([
177
177
  ReticleTool.ACT_AND_WAIT,
178
178
  ReticleTool.ASSERT,
179
179
  // Merged: `flow_verify` and `verify_change` are actions on this tool now. Naming the merged tool
@@ -206,7 +206,7 @@ let verificationsSincePrompt = 0;
206
206
  * collect is worse than not asking: it spends their attention on a message with nowhere to go.
207
207
  */
208
208
  export function takeFeedbackPrompt(toolName) {
209
- if (!VERIFICATION_TOOLS.has(toolName))
209
+ if (!VERDICT_TOOLS.has(toolName))
210
210
  return undefined;
211
211
  if (feedbackDisabled() || !getTelemetry().enabled)
212
212
  return undefined;