@reticlehq/server 2.10.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 (290) hide show
  1. package/SKILL.md +73 -11
  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 +41 -0
  45. package/dist/dev/stale-issue-guard.js +75 -0
  46. package/dist/domain/domain-tools.js +3 -2
  47. package/dist/events/contradictions.d.ts +41 -0
  48. package/dist/events/contradictions.js +125 -12
  49. package/dist/events/declared.d.ts +14 -0
  50. package/dist/events/declared.js +33 -0
  51. package/dist/events/echo-mismatch.js +14 -0
  52. package/dist/events/observed-in-window.d.ts +12 -0
  53. package/dist/events/observed-in-window.js +17 -0
  54. package/dist/events/predicate-asks.d.ts +30 -0
  55. package/dist/events/predicate-asks.js +48 -0
  56. package/dist/events/predicate-eval.js +254 -25
  57. package/dist/events/predicate-parse.d.ts +0 -6
  58. package/dist/events/predicate-parse.js +51 -5
  59. package/dist/events/predicate-schema.d.ts +62 -0
  60. package/dist/events/predicate-schema.js +162 -4
  61. package/dist/events/predicate.d.ts +15 -0
  62. package/dist/events/predicate.js +41 -2
  63. package/dist/flows/coverage.d.ts +0 -8
  64. package/dist/flows/coverage.js +1 -1
  65. package/dist/flows/decision.d.ts +5 -1
  66. package/dist/flows/decision.js +33 -7
  67. package/dist/flows/flake.d.ts +0 -2
  68. package/dist/flows/flake.js +1 -1
  69. package/dist/flows/flow-intent.d.ts +52 -0
  70. package/dist/flows/flow-intent.js +123 -0
  71. package/dist/flows/flow-replay-run.js +27 -4
  72. package/dist/flows/flow-success.js +15 -3
  73. package/dist/flows/flow-tools.js +65 -8
  74. package/dist/flows/flows.d.ts +6 -1
  75. package/dist/flows/flows.js +34 -22
  76. package/dist/flows/predicate-to-expect.js +14 -3
  77. package/dist/flows/server-verify.js +16 -2
  78. package/dist/flows/verify-change-tools.js +9 -2
  79. package/dist/honesty/blind-spots.d.ts +8 -16
  80. package/dist/honesty/blind-spots.js +18 -5
  81. package/dist/honesty/feature-capture.d.ts +150 -0
  82. package/dist/honesty/feature-capture.js +233 -0
  83. package/dist/honesty/gap-ledger.d.ts +52 -0
  84. package/dist/honesty/gap-ledger.js +63 -0
  85. package/dist/honesty/instrumentation-gaps.d.ts +79 -0
  86. package/dist/honesty/instrumentation-gaps.js +77 -0
  87. package/dist/honesty/observability.d.ts +37 -0
  88. package/dist/honesty/observability.js +40 -0
  89. package/dist/honesty/tool-hit-rate.d.ts +86 -0
  90. package/dist/honesty/tool-hit-rate.js +90 -0
  91. package/dist/honesty/undeclared-change.d.ts +51 -0
  92. package/dist/honesty/undeclared-change.js +56 -0
  93. package/dist/honesty/verified.d.ts +15 -0
  94. package/dist/honesty/verified.js +68 -4
  95. package/dist/http-server.d.ts +0 -1
  96. package/dist/http-server.js +1 -1
  97. package/dist/hunt/hunt-report.d.ts +0 -2
  98. package/dist/hunt/hunt-report.js +1 -1
  99. package/dist/impact/impact-store.d.ts +0 -5
  100. package/dist/impact/impact-store.js +1 -1
  101. package/dist/index.d.ts +4 -2
  102. package/dist/index.js +41 -0
  103. package/dist/init/agent-rules.d.ts +17 -0
  104. package/dist/init/agent-rules.js +31 -1
  105. package/dist/init/capabilities.d.ts +0 -2
  106. package/dist/init/capabilities.js +1 -1
  107. package/dist/init/closing-hint.d.ts +36 -0
  108. package/dist/init/closing-hint.js +120 -0
  109. package/dist/init/confirm.d.ts +0 -25
  110. package/dist/init/confirm.js +3 -3
  111. package/dist/init/cra.d.ts +14 -1
  112. package/dist/init/cra.js +43 -12
  113. package/dist/init/dev-script.d.ts +48 -0
  114. package/dist/init/dev-script.js +63 -0
  115. package/dist/init/mcp.d.ts +16 -5
  116. package/dist/init/mcp.js +26 -10
  117. package/dist/init/node-io.js +3 -0
  118. package/dist/init/plan-framework.d.ts +0 -5
  119. package/dist/init/plan-framework.js +83 -9
  120. package/dist/init/plan.d.ts +10 -30
  121. package/dist/init/plan.js +30 -15
  122. package/dist/init/refresh-rules.d.ts +39 -0
  123. package/dist/init/refresh-rules.js +51 -0
  124. package/dist/init/run.d.ts +2 -10
  125. package/dist/init/run.js +26 -68
  126. package/dist/init/slash-command.d.ts +1 -1
  127. package/dist/init/slash-command.js +33 -10
  128. package/dist/init/snippets.js +53 -10
  129. package/dist/input/real-input.d.ts +2 -0
  130. package/dist/input/real-input.js +3 -2
  131. package/dist/input/scroll-find.d.ts +7 -4
  132. package/dist/input/scroll-find.js +63 -25
  133. package/dist/intent/inline-intent.d.ts +46 -0
  134. package/dist/intent/inline-intent.js +103 -0
  135. package/dist/intent/intent-store.d.ts +37 -0
  136. package/dist/intent/intent-store.js +112 -0
  137. package/dist/intent/intent-tools.d.ts +2 -0
  138. package/dist/intent/intent-tools.js +80 -0
  139. package/dist/intent/open-intents.d.ts +21 -0
  140. package/dist/intent/open-intents.js +25 -0
  141. package/dist/journal/envelope.d.ts +2 -2
  142. package/dist/journal/journal-recorder.d.ts +23 -0
  143. package/dist/journal/journal-recorder.js +35 -0
  144. package/dist/license/license.d.ts +8 -2
  145. package/dist/license/license.js +10 -15
  146. package/dist/mcp/attach-memory.d.ts +67 -0
  147. package/dist/mcp/attach-memory.js +142 -0
  148. package/dist/mcp/mcp-proxy.d.ts +0 -7
  149. package/dist/mcp/mcp-proxy.js +14 -2
  150. package/dist/mcp/mcp.d.ts +2 -0
  151. package/dist/mcp/mcp.js +45 -23
  152. package/dist/mcp/server-instructions.js +45 -3
  153. package/dist/mcp/tool-catalog-cache.d.ts +8 -0
  154. package/dist/mcp/tool-catalog-cache.js +10 -0
  155. package/dist/oracles/flow-instrument-gaps.d.ts +4 -4
  156. package/dist/oracles/flow-instrument-gaps.js +4 -5
  157. package/dist/oracles/self-instrument.d.ts +9 -9
  158. package/dist/oracles/self-instrument.js +19 -13
  159. package/dist/pool/browser-pool.d.ts +12 -1
  160. package/dist/pool/browser-pool.js +31 -1
  161. package/dist/pool/playwright-launcher.js +3 -1
  162. package/dist/project/artifact-root.d.ts +83 -0
  163. package/dist/project/artifact-root.js +112 -0
  164. package/dist/project/fs-port.d.ts +3 -0
  165. package/dist/project/fs-port.js +3 -2
  166. package/dist/project/memory-fs.d.ts +9 -0
  167. package/dist/project/memory-fs.js +93 -0
  168. package/dist/project/project-store.d.ts +17 -0
  169. package/dist/project/project-store.js +33 -0
  170. package/dist/project/project-tools.js +3 -32
  171. package/dist/project/remember-project.d.ts +24 -0
  172. package/dist/project/remember-project.js +40 -0
  173. package/dist/project/reticle-dir.d.ts +2 -0
  174. package/dist/project/reticle-dir.js +1 -0
  175. package/dist/project/session-root.d.ts +15 -0
  176. package/dist/project/session-root.js +31 -0
  177. package/dist/runs/context-tools.d.ts +2 -0
  178. package/dist/runs/context-tools.js +89 -0
  179. package/dist/runs/run-context.d.ts +40 -0
  180. package/dist/runs/run-context.js +158 -0
  181. package/dist/runs/run-diff.d.ts +2 -1
  182. package/dist/runs/run-tools.js +2 -1
  183. package/dist/runs/verification-sync.js +2 -2
  184. package/dist/session/dev-server-probe.d.ts +0 -8
  185. package/dist/session/dev-server-probe.js +1 -1
  186. package/dist/session/last-act.d.ts +7 -1
  187. package/dist/session/last-act.js +2 -2
  188. package/dist/session/lease-availability.d.ts +40 -0
  189. package/dist/session/lease-availability.js +43 -0
  190. package/dist/session/live-control-tools.js +1 -42
  191. package/dist/session/no-session-diagnosis.d.ts +20 -0
  192. package/dist/session/no-session-diagnosis.js +41 -12
  193. package/dist/session/no-session-next-action.d.ts +5 -0
  194. package/dist/session/no-session-next-action.js +35 -3
  195. package/dist/session/no-session-watch.d.ts +8 -0
  196. package/dist/session/no-session-watch.js +83 -65
  197. package/dist/session/output-budget.js +4 -1
  198. package/dist/session/session-health.d.ts +9 -0
  199. package/dist/session/session-health.js +23 -0
  200. package/dist/session/session-manager.js +50 -1
  201. package/dist/session/session-successor.d.ts +37 -0
  202. package/dist/session/session-successor.js +78 -0
  203. package/dist/session/session.d.ts +40 -1
  204. package/dist/session/session.js +84 -2
  205. package/dist/session/stall-clock.d.ts +18 -0
  206. package/dist/session/stall-clock.js +48 -0
  207. package/dist/telemetry/automation-hint.d.ts +2 -1
  208. package/dist/telemetry/daemon-telemetry.js +12 -23
  209. package/dist/telemetry/feedback-outbox.d.ts +2 -1
  210. package/dist/telemetry/project-profile.d.ts +0 -8
  211. package/dist/telemetry/project-profile.js +1 -1
  212. package/dist/telemetry/run-telemetry.d.ts +0 -2
  213. package/dist/telemetry/run-telemetry.js +1 -1
  214. package/dist/telemetry/session-metrics.d.ts +1 -1
  215. package/dist/telemetry/session-metrics.js +1 -1
  216. package/dist/telemetry/telemetry.d.ts +12 -3
  217. package/dist/telemetry/telemetry.js +25 -5
  218. package/dist/telemetry/verification-of.js +2 -2
  219. package/dist/tools/act-tools.js +159 -24
  220. package/dist/tools/assert-source.d.ts +22 -0
  221. package/dist/tools/assert-source.js +53 -0
  222. package/dist/tools/assert-verdict.d.ts +21 -2
  223. package/dist/tools/assert-verdict.js +59 -4
  224. package/dist/tools/browser-tools.js +0 -20
  225. package/dist/tools/complete-snapshot.d.ts +48 -0
  226. package/dist/tools/complete-snapshot.js +75 -0
  227. package/dist/tools/contract-tools.js +5 -3
  228. package/dist/tools/coverage-tools.d.ts +10 -1
  229. package/dist/tools/coverage-tools.js +124 -37
  230. package/dist/tools/dynamic-tools.js +29 -8
  231. package/dist/tools/error-recovery.d.ts +24 -14
  232. package/dist/tools/error-recovery.js +70 -9
  233. package/dist/tools/feedback-tools.d.ts +1 -1
  234. package/dist/tools/feedback-tools.js +2 -2
  235. package/dist/tools/invoke-tool.js +62 -2
  236. package/dist/tools/lease-hint.d.ts +10 -0
  237. package/dist/tools/lease-hint.js +53 -4
  238. package/dist/tools/lease-tools.d.ts +5 -1
  239. package/dist/tools/lease-tools.js +22 -8
  240. package/dist/tools/navigate-arrival.d.ts +0 -2
  241. package/dist/tools/navigate-arrival.js +1 -1
  242. package/dist/tools/observe-tools.js +70 -27
  243. package/dist/tools/read-tools.js +5 -3
  244. package/dist/tools/real-input-attempt.d.ts +22 -0
  245. package/dist/tools/real-input-attempt.js +153 -1
  246. package/dist/tools/reconcile-tools.js +18 -4
  247. package/dist/tools/resolve-target.d.ts +0 -7
  248. package/dist/tools/sdk-marker-probe.d.ts +29 -0
  249. package/dist/tools/sdk-marker-probe.js +57 -0
  250. package/dist/tools/snapshot-delta.d.ts +1 -1
  251. package/dist/tools/tool-kit.d.ts +28 -1
  252. package/dist/tools/tool-kit.js +16 -0
  253. package/dist/tools/tool-names.d.ts +3 -0
  254. package/dist/tools/tool-names.js +3 -0
  255. package/dist/tools/tool-surface.d.ts +85 -6
  256. package/dist/tools/tool-surface.js +122 -2
  257. package/dist/tools/tools.js +22 -4
  258. package/dist/tools/unadvertised-help.js +4 -4
  259. package/dist/tools/verify-next-baton.d.ts +53 -0
  260. package/dist/tools/verify-next-baton.js +52 -0
  261. package/dist/update/update-nudge.d.ts +7 -10
  262. package/dist/update/update-nudge.js +12 -3
  263. package/dist/update/updater.d.ts +1 -1
  264. package/docs/README.md +3 -1
  265. package/docs/cli/cloud.mdx +1 -1
  266. package/docs/cli/doctor.mdx +1 -0
  267. package/docs/cli/verify.mdx +4 -5
  268. package/docs/desktop.mdx +1 -1
  269. package/docs/docs.json +3 -3
  270. package/docs/enterprise.md +3 -1
  271. package/docs/gates.md +1 -1
  272. package/docs/install-manual.mdx +1 -1
  273. package/docs/license-activation.mdx +145 -0
  274. package/docs/packages/browser.mdx +2 -2
  275. package/docs/packages/server.mdx +1 -1
  276. package/docs/packages.mdx +8 -6
  277. package/docs/platform-integration.md +17 -0
  278. package/docs/predicates.mdx +8 -0
  279. package/docs/reticle-manual.md +106 -0
  280. package/docs/telemetry-contract.md +1 -12
  281. package/docs/telemetry-events.mdx +1 -34
  282. package/docs/telemetry.md +0 -1
  283. package/docs/tools-tools-and-run.mdx +2 -4
  284. package/docs/troubleshooting.mdx +9 -0
  285. package/docs/usage.md +3 -2
  286. package/package.json +2 -2
  287. package/dist/session/session-readiness.d.ts +0 -34
  288. package/dist/session/session-readiness.js +0 -36
  289. package/dist/telemetry/instrumentation-stall.d.ts +0 -57
  290. package/dist/telemetry/instrumentation-stall.js +0 -103
@@ -0,0 +1,150 @@
1
+ import { type Intent, type JournalAction } from '@reticlehq/core';
2
+ /**
3
+ * Were `reticle_context` and `reticle_intent` used at all — and did using them change anything?
4
+ *
5
+ * Both shipped with the same pre-registered disproof: *if agents never call it, cut it*. Nothing
6
+ * recorded whether they were called, so the disproof could never be run, and a feature whose
7
+ * disproof cannot be run is one nobody can kill. This is the smallest thing that makes the question
8
+ * answerable, per session, locally — an instrument, not a scold. The numbers exist so a FEATURE can
9
+ * be removed, never so an agent can be blamed for how it drove.
10
+ *
11
+ * ## Fold first, record only what nothing records
12
+ *
13
+ * Three of the four questions are already answerable from what is on disk. The intent ledger says
14
+ * what was declared; the journal says what was driven and what verdicts were drawn. Those are folded
15
+ * here and never re-stored, for the reason `run-context.ts` gives at length: two ways to compute one
16
+ * number is worse than one way.
17
+ *
18
+ * The journal does NOT record read-only calls — it holds actions and verdicts only. So "was
19
+ * `reticle_context` called" has no ledger to fold, and that one thing is recorded, in memory, on the
20
+ * session, by `CaptureLedger`. It is the whole of the new state.
21
+ *
22
+ * ## Absent is not zero
23
+ *
24
+ * A session this daemon has recorded nothing for — journaling off, a session adopted after the fact
25
+ * — reports `observed: false` and states no counts at all. It has not established that the features
26
+ * went unused; it has established that it was not watching. This repo has shipped the other bug
27
+ * twice.
28
+ */
29
+ /**
30
+ * The calls this instrument needs and the journal does not keep.
31
+ *
32
+ * Deliberately a short explicit list rather than "everything that is not an action". Under-inclusion
33
+ * is the safe direction: a read tool missing from here lowers the hit side and the miss side by the
34
+ * same call, so the comparison the disproof turns on stays fair. Over-inclusion is not — a lifecycle
35
+ * or disk-side tool landing in here would be counted as a "read" the agent made of the app, which it
36
+ * was not.
37
+ */
38
+ export declare const CAPTURED_TOOLS: ReadonlySet<string>;
39
+ /** One recorded call: which tool, what it was about, and how far into the run it happened. */
40
+ export interface CapturedCall {
41
+ tool: string;
42
+ /** The ref or target the call named, when it named one. Absent for a whole-page read. */
43
+ subject?: string;
44
+ /**
45
+ * The session's dispatched-action count at the moment of the call — the same counter that mints
46
+ * journal action ids, so it lines up with `reticle_context`'s own `step` whenever the journal is on.
47
+ */
48
+ afterActions: number;
49
+ }
50
+ /**
51
+ * The one thing that is stored rather than folded: the read-only calls the journal never sees.
52
+ *
53
+ * In memory and per session, the same shape as `GapLedger`. It is not durable on purpose — the
54
+ * question is about one run, and a second file on disk would be a second thing to reconcile with the
55
+ * journal.
56
+ */
57
+ export declare class CaptureLedger {
58
+ #private;
59
+ /**
60
+ * One call, under the name of the tool that RAN — every tool, not just the read set above.
61
+ *
62
+ * Separate from `note` because it answers a different question and is bounded differently: this
63
+ * map cannot outgrow the tool table however long the session runs, so it needs no cap and never
64
+ * has to report a floor. See honesty/tool-hit-rate.ts for what it feeds.
65
+ */
66
+ noteTool(tool: string): void;
67
+ /** How many times each tool was called this session, in first-call order. */
68
+ toolCalls(): ReadonlyMap<string, number>;
69
+ note(call: CapturedCall): void;
70
+ calls(): readonly CapturedCall[];
71
+ /** How many calls fell off the front. Non-zero means the counts below are a floor. */
72
+ get dropped(): number;
73
+ }
74
+ /**
75
+ * Record one captured call against the session that made it.
76
+ *
77
+ * Tolerates a session that carries no ledger, exactly as `noteSessionGaps` does and for the same
78
+ * reason: `Session` is satisfied structurally, and many specs — plus any consumer embedding this
79
+ * engine — build one as an object literal. Such a session is a real caller that simply keeps no
80
+ * ledger, and an instrument must never be the reason a tool call throws. Its calls are then absent
81
+ * rather than counted as zero, which is what `observed: false` exists to say.
82
+ */
83
+ export declare function noteCapturedCall(session: {
84
+ capture?: CaptureLedger;
85
+ actionCount?: number;
86
+ }, call: Omit<CapturedCall, 'afterActions'>): void;
87
+ /**
88
+ * Count one call against the session that made it, whichever tool it was.
89
+ *
90
+ * Tolerant of a ledger-less session for exactly the reason `noteCapturedCall` is: its calls are then
91
+ * absent rather than counted as zero, which is what `observed: false` exists to say.
92
+ */
93
+ export declare function noteToolDispatched(session: {
94
+ capture?: CaptureLedger;
95
+ }, tool: string): void;
96
+ /** What one session's use of the two features looks like. Everything but `observed` is omitted when nothing was recorded. */
97
+ export interface FeatureCapture {
98
+ /** False when this daemon recorded nothing for the session — "not watched", never "not used". */
99
+ observed: boolean;
100
+ /** The ledger dropped calls, so every count is a floor. Present only when true. */
101
+ truncated?: boolean;
102
+ context?: {
103
+ calls: number;
104
+ /** The dispatched-action count each call was made at, in order. */
105
+ atSteps: number[];
106
+ acted: number;
107
+ refetched: number;
108
+ readOther: number;
109
+ nothingAfter: number;
110
+ };
111
+ intents?: {
112
+ declared: number;
113
+ open: number;
114
+ };
115
+ missed?: {
116
+ verdictsWithNoIntentDeclared: number;
117
+ refetchedEstablished: number;
118
+ };
119
+ }
120
+ /**
121
+ * Did calling `reticle_context` change what happened next?
122
+ *
123
+ * The mechanical proxy, stated so it can be argued with: look at the very next recorded call. If an
124
+ * action was dispatched before it — the action counter moved — the agent ACTED, and the context
125
+ * plausibly helped. If instead the next call is a read of a subject the context had just supplied,
126
+ * the agent re-fetched what it had been handed, and the context did not help.
127
+ *
128
+ * ## What this proxy CANNOT see
129
+ *
130
+ * - Whether the agent READ the response, reasoned over it, or wrote a better answer from it. There
131
+ * is no signal for that anywhere, and this must not be mistaken for one.
132
+ * - Anything that happens between two Reticle calls: source edits, calls to other MCP servers, a
133
+ * turn ending, a human interrupting. A long gap is invisible and reads as adjacency.
134
+ * - Reads that name no ref or target (a whole-page snapshot). They land in `readOther`, never in
135
+ * `refetched`, so a snapshot that duplicated the context is under-counted, not over-counted.
136
+ * - Causation. `acted` is proximity in a call sequence, nothing more. An agent that would have acted
137
+ * anyway is indistinguishable from one the context unblocked.
138
+ * - `reticle_context` called with no session connected — it answers there by design, and there is no
139
+ * per-session ledger to record it against, so those calls are missing entirely.
140
+ * - Two agents driving one session. Their calls interleave into one sequence and "next call" stops
141
+ * meaning "next thing that agent did".
142
+ */
143
+ export declare function foldFeatureCapture(input: {
144
+ calls: readonly CapturedCall[];
145
+ dropped: number;
146
+ actions: readonly JournalAction[];
147
+ intents: readonly Intent[];
148
+ /** The session's dispatched-action count now, so a run that ended on an action is not read as idle. */
149
+ finalActions: number;
150
+ }): FeatureCapture;
@@ -0,0 +1,233 @@
1
+ import { IntentState, JournalVerdictEffectSchema, RUN_ESTABLISHED_CAP, } from '@reticlehq/core';
2
+ import { subjectOf } from '../runs/run-context.js';
3
+ import { ReticleTool } from '../tools/tool-names.js';
4
+ /**
5
+ * Were `reticle_context` and `reticle_intent` used at all — and did using them change anything?
6
+ *
7
+ * Both shipped with the same pre-registered disproof: *if agents never call it, cut it*. Nothing
8
+ * recorded whether they were called, so the disproof could never be run, and a feature whose
9
+ * disproof cannot be run is one nobody can kill. This is the smallest thing that makes the question
10
+ * answerable, per session, locally — an instrument, not a scold. The numbers exist so a FEATURE can
11
+ * be removed, never so an agent can be blamed for how it drove.
12
+ *
13
+ * ## Fold first, record only what nothing records
14
+ *
15
+ * Three of the four questions are already answerable from what is on disk. The intent ledger says
16
+ * what was declared; the journal says what was driven and what verdicts were drawn. Those are folded
17
+ * here and never re-stored, for the reason `run-context.ts` gives at length: two ways to compute one
18
+ * number is worse than one way.
19
+ *
20
+ * The journal does NOT record read-only calls — it holds actions and verdicts only. So "was
21
+ * `reticle_context` called" has no ledger to fold, and that one thing is recorded, in memory, on the
22
+ * session, by `CaptureLedger`. It is the whole of the new state.
23
+ *
24
+ * ## Absent is not zero
25
+ *
26
+ * A session this daemon has recorded nothing for — journaling off, a session adopted after the fact
27
+ * — reports `observed: false` and states no counts at all. It has not established that the features
28
+ * went unused; it has established that it was not watching. This repo has shipped the other bug
29
+ * twice.
30
+ */
31
+ /**
32
+ * The calls this instrument needs and the journal does not keep.
33
+ *
34
+ * Deliberately a short explicit list rather than "everything that is not an action". Under-inclusion
35
+ * is the safe direction: a read tool missing from here lowers the hit side and the miss side by the
36
+ * same call, so the comparison the disproof turns on stays fair. Over-inclusion is not — a lifecycle
37
+ * or disk-side tool landing in here would be counted as a "read" the agent made of the app, which it
38
+ * was not.
39
+ */
40
+ export const CAPTURED_TOOLS = new Set([
41
+ ReticleTool.CONTEXT,
42
+ ReticleTool.SNAPSHOT,
43
+ ReticleTool.QUERY,
44
+ ReticleTool.INSPECT,
45
+ ReticleTool.STATE,
46
+ ReticleTool.STORAGE,
47
+ ReticleTool.NETWORK,
48
+ ReticleTool.CONSOLE,
49
+ ReticleTool.OBSERVE,
50
+ ReticleTool.EXPLORE,
51
+ ]);
52
+ /**
53
+ * How many calls the ledger holds before it starts dropping the oldest.
54
+ *
55
+ * A verification loop is 50–200 calls, so this is not reached in normal use; it exists so a runaway
56
+ * session cannot grow a per-session array without bound. When it IS reached the report says
57
+ * `truncated` rather than presenting a short count as a complete one.
58
+ */
59
+ const CAPTURE_CAP = 2000;
60
+ /**
61
+ * The one thing that is stored rather than folded: the read-only calls the journal never sees.
62
+ *
63
+ * In memory and per session, the same shape as `GapLedger`. It is not durable on purpose — the
64
+ * question is about one run, and a second file on disk would be a second thing to reconcile with the
65
+ * journal.
66
+ */
67
+ export class CaptureLedger {
68
+ #calls = [];
69
+ #dropped = 0;
70
+ #tools = new Map();
71
+ /**
72
+ * One call, under the name of the tool that RAN — every tool, not just the read set above.
73
+ *
74
+ * Separate from `note` because it answers a different question and is bounded differently: this
75
+ * map cannot outgrow the tool table however long the session runs, so it needs no cap and never
76
+ * has to report a floor. See honesty/tool-hit-rate.ts for what it feeds.
77
+ */
78
+ noteTool(tool) {
79
+ this.#tools.set(tool, (this.#tools.get(tool) ?? 0) + 1);
80
+ }
81
+ /** How many times each tool was called this session, in first-call order. */
82
+ toolCalls() {
83
+ return this.#tools;
84
+ }
85
+ note(call) {
86
+ this.#calls.push(call);
87
+ if (this.#calls.length > CAPTURE_CAP) {
88
+ this.#calls.shift();
89
+ this.#dropped += 1;
90
+ }
91
+ }
92
+ calls() {
93
+ return this.#calls;
94
+ }
95
+ /** How many calls fell off the front. Non-zero means the counts below are a floor. */
96
+ get dropped() {
97
+ return this.#dropped;
98
+ }
99
+ }
100
+ /**
101
+ * Record one captured call against the session that made it.
102
+ *
103
+ * Tolerates a session that carries no ledger, exactly as `noteSessionGaps` does and for the same
104
+ * reason: `Session` is satisfied structurally, and many specs — plus any consumer embedding this
105
+ * engine — build one as an object literal. Such a session is a real caller that simply keeps no
106
+ * ledger, and an instrument must never be the reason a tool call throws. Its calls are then absent
107
+ * rather than counted as zero, which is what `observed: false` exists to say.
108
+ */
109
+ export function noteCapturedCall(session, call) {
110
+ session.capture?.note({ ...call, afterActions: session.actionCount ?? 0 });
111
+ }
112
+ /**
113
+ * Count one call against the session that made it, whichever tool it was.
114
+ *
115
+ * Tolerant of a ledger-less session for exactly the reason `noteCapturedCall` is: its calls are then
116
+ * absent rather than counted as zero, which is what `observed: false` exists to say.
117
+ */
118
+ export function noteToolDispatched(session, tool) {
119
+ session.capture?.noteTool(tool);
120
+ }
121
+ /**
122
+ * Every point in the run at which each subject became an established fact.
123
+ *
124
+ * "Established" is not a second definition: it is the rule `establishedFromJournal` folds by — an
125
+ * action with an observed settle outcome, filed under the ref or target it named — and `subjectOf`
126
+ * is imported from there rather than rewritten, so the two cannot drift apart.
127
+ */
128
+ function establishedIndexes(actions) {
129
+ const out = new Map();
130
+ actions.forEach((action, index) => {
131
+ if (undefined === action.settled)
132
+ return;
133
+ const key = subjectOf(action);
134
+ if (undefined === key)
135
+ return;
136
+ const at = out.get(key);
137
+ if (at === undefined)
138
+ out.set(key, [index]);
139
+ else
140
+ at.push(index);
141
+ });
142
+ return out;
143
+ }
144
+ /** Was this subject already an established fact by the time a call at `afterActions` was made? */
145
+ function wasEstablished(index, subject, afterActions) {
146
+ if (undefined === subject)
147
+ return false;
148
+ const at = index.get(subject);
149
+ if (at === undefined)
150
+ return false;
151
+ // Action `i` has been dispatched once the counter reads `i + 1`, so `i + 1 <= afterActions` is
152
+ // "already true when the call was made" — which is what keeps a FIRST look off this count. The
153
+ // lower bound is the run context's own window: a fact that has scrolled past RUN_ESTABLISHED_CAP
154
+ // is not something `reticle_context` would have handed back, so re-reading it is not a re-fetch.
155
+ return at.some((i) => i + 1 <= afterActions && i + 1 > afterActions - RUN_ESTABLISHED_CAP);
156
+ }
157
+ /**
158
+ * Did calling `reticle_context` change what happened next?
159
+ *
160
+ * The mechanical proxy, stated so it can be argued with: look at the very next recorded call. If an
161
+ * action was dispatched before it — the action counter moved — the agent ACTED, and the context
162
+ * plausibly helped. If instead the next call is a read of a subject the context had just supplied,
163
+ * the agent re-fetched what it had been handed, and the context did not help.
164
+ *
165
+ * ## What this proxy CANNOT see
166
+ *
167
+ * - Whether the agent READ the response, reasoned over it, or wrote a better answer from it. There
168
+ * is no signal for that anywhere, and this must not be mistaken for one.
169
+ * - Anything that happens between two Reticle calls: source edits, calls to other MCP servers, a
170
+ * turn ending, a human interrupting. A long gap is invisible and reads as adjacency.
171
+ * - Reads that name no ref or target (a whole-page snapshot). They land in `readOther`, never in
172
+ * `refetched`, so a snapshot that duplicated the context is under-counted, not over-counted.
173
+ * - Causation. `acted` is proximity in a call sequence, nothing more. An agent that would have acted
174
+ * anyway is indistinguishable from one the context unblocked.
175
+ * - `reticle_context` called with no session connected — it answers there by design, and there is no
176
+ * per-session ledger to record it against, so those calls are missing entirely.
177
+ * - Two agents driving one session. Their calls interleave into one sequence and "next call" stops
178
+ * meaning "next thing that agent did".
179
+ */
180
+ export function foldFeatureCapture(input) {
181
+ const { calls, actions, intents } = input;
182
+ if (0 === calls.length && 0 === actions.length)
183
+ return { observed: false };
184
+ const established = establishedIndexes(actions);
185
+ const isRefetch = (call) => ReticleTool.CONTEXT !== call.tool &&
186
+ wasEstablished(established, call.subject, call.afterActions);
187
+ let acted = 0;
188
+ let refetched = 0;
189
+ let readOther = 0;
190
+ let nothingAfter = 0;
191
+ const atSteps = [];
192
+ calls.forEach((call, i) => {
193
+ if (ReticleTool.CONTEXT !== call.tool)
194
+ return;
195
+ atSteps.push(call.afterActions);
196
+ const next = calls[i + 1];
197
+ if (next === undefined) {
198
+ if (input.finalActions > call.afterActions)
199
+ acted += 1;
200
+ else
201
+ nothingAfter += 1;
202
+ return;
203
+ }
204
+ if (next.afterActions > call.afterActions)
205
+ acted += 1;
206
+ else if (isRefetch(next))
207
+ refetched += 1;
208
+ else
209
+ readOther += 1;
210
+ });
211
+ // A verdict is any journal action carrying the bounded verdict effect both verification tools
212
+ // write. Parsed with core's own schema rather than a local shape, for the usual reason.
213
+ const verdicts = actions.filter((a) => JournalVerdictEffectSchema.safeParse(a.effect).success);
214
+ return {
215
+ observed: true,
216
+ ...(input.dropped > 0 ? { truncated: true } : {}),
217
+ context: { calls: atSteps.length, atSteps, acted, refetched, readOther, nothingAfter },
218
+ intents: {
219
+ declared: intents.length,
220
+ open: intents.filter((it) => IntentState.PROVED !== it.state).length,
221
+ },
222
+ missed: {
223
+ // The coarse question, asked honestly. Nothing binds a verdict to a specific intent outside
224
+ // flow replay, so "covered" cannot mean "this intent proves this verdict" without inventing a
225
+ // link Reticle cannot observe — the same reason `undeclared-change.ts` asks only whether
226
+ // anything at all was declared. So this counts verdicts drawn against an EMPTY ledger, and
227
+ // stops at zero once anything is declared. That zero is not a claim that every verdict was
228
+ // covered; it is the end of what this can see.
229
+ verdictsWithNoIntentDeclared: 0 === intents.length ? verdicts.length : 0,
230
+ refetchedEstablished: calls.filter(isRefetch).length,
231
+ },
232
+ };
233
+ }
@@ -0,0 +1,52 @@
1
+ import { type InstrumentationGap } from '@reticlehq/core';
2
+ /**
3
+ * What this session still cannot see, accumulated across its verdicts.
4
+ *
5
+ * A gap reported on one verdict and then forgotten is a note. A gap that is still standing when the
6
+ * agent asks "am I done?" is a piece of work — and that difference is the entire discipline
7
+ * mechanism, because an agent finishing a task is the one moment it will act on a cost it can close.
8
+ *
9
+ * ## Why the LAST verdict decides, not the whole history
10
+ *
11
+ * The honest question is "is this app still unverifiable in this way", not "was it ever". An agent
12
+ * that hits a gap, adds the build plugin and re-verifies has CLOSED it — the later verdict simply
13
+ * stops emitting that kind — and a ledger that still reported it would punish exactly the behaviour
14
+ * this exists to cause. Nobody instruments an app twice for a tool that never notices the first time.
15
+ *
16
+ * So `open()` reflects the most recent verdict, and `everSeen` is kept separately for the summary,
17
+ * where "you hit this and fixed it" is worth saying and is not a reason to withhold done.
18
+ */
19
+ export declare class GapLedger {
20
+ #private;
21
+ /**
22
+ * Record what the verdict just reported. An empty list is a real observation — it is how a gap
23
+ * gets closed — so it must not be ignored as "nothing to record".
24
+ */
25
+ note(gaps: readonly InstrumentationGap[]): void;
26
+ /** Gaps the most recent verdict still reported. The ones that are work right now. */
27
+ open(): readonly InstrumentationGap[];
28
+ /** Every kind seen in this session, including ones since closed. */
29
+ get everSeen(): readonly string[];
30
+ /**
31
+ * Did this session leave the app less verifiable than it could be?
32
+ *
33
+ * The one question a "have I finished" call needs answered, and the one a session summary turns
34
+ * into `unproven` rather than `verified`.
35
+ */
36
+ get hasOpen(): boolean;
37
+ }
38
+ /**
39
+ * Record a verdict's gaps against the session that produced it.
40
+ *
41
+ * Tolerates a session with no ledger, and that is a statement about the seam rather than a hedge:
42
+ * `Session` is satisfied structurally, and dozens of specs — plus any consumer embedding this engine
43
+ * — build one as an object literal rather than through the constructor. Such a session is a real
44
+ * caller, it simply keeps no ledger, and recording is a side effect that must never be the reason a
45
+ * verdict fails to return.
46
+ *
47
+ * One helper rather than an optional-chain at each call site: the tolerance is a decision, and a
48
+ * decision spelled `?.` in two places is one nobody can find later.
49
+ */
50
+ export declare function noteSessionGaps(session: {
51
+ gaps?: GapLedger;
52
+ }, gaps: readonly InstrumentationGap[]): void;
@@ -0,0 +1,63 @@
1
+ import { dedupeGaps } from '@reticlehq/core';
2
+ /**
3
+ * What this session still cannot see, accumulated across its verdicts.
4
+ *
5
+ * A gap reported on one verdict and then forgotten is a note. A gap that is still standing when the
6
+ * agent asks "am I done?" is a piece of work — and that difference is the entire discipline
7
+ * mechanism, because an agent finishing a task is the one moment it will act on a cost it can close.
8
+ *
9
+ * ## Why the LAST verdict decides, not the whole history
10
+ *
11
+ * The honest question is "is this app still unverifiable in this way", not "was it ever". An agent
12
+ * that hits a gap, adds the build plugin and re-verifies has CLOSED it — the later verdict simply
13
+ * stops emitting that kind — and a ledger that still reported it would punish exactly the behaviour
14
+ * this exists to cause. Nobody instruments an app twice for a tool that never notices the first time.
15
+ *
16
+ * So `open()` reflects the most recent verdict, and `everSeen` is kept separately for the summary,
17
+ * where "you hit this and fixed it" is worth saying and is not a reason to withhold done.
18
+ */
19
+ export class GapLedger {
20
+ #open = [];
21
+ #everSeen = new Set();
22
+ /**
23
+ * Record what the verdict just reported. An empty list is a real observation — it is how a gap
24
+ * gets closed — so it must not be ignored as "nothing to record".
25
+ */
26
+ note(gaps) {
27
+ this.#open = dedupeGaps(gaps);
28
+ for (const gap of gaps)
29
+ this.#everSeen.add(gap.kind);
30
+ }
31
+ /** Gaps the most recent verdict still reported. The ones that are work right now. */
32
+ open() {
33
+ return this.#open;
34
+ }
35
+ /** Every kind seen in this session, including ones since closed. */
36
+ get everSeen() {
37
+ return [...this.#everSeen];
38
+ }
39
+ /**
40
+ * Did this session leave the app less verifiable than it could be?
41
+ *
42
+ * The one question a "have I finished" call needs answered, and the one a session summary turns
43
+ * into `unproven` rather than `verified`.
44
+ */
45
+ get hasOpen() {
46
+ return this.#open.length > 0;
47
+ }
48
+ }
49
+ /**
50
+ * Record a verdict's gaps against the session that produced it.
51
+ *
52
+ * Tolerates a session with no ledger, and that is a statement about the seam rather than a hedge:
53
+ * `Session` is satisfied structurally, and dozens of specs — plus any consumer embedding this engine
54
+ * — build one as an object literal rather than through the constructor. Such a session is a real
55
+ * caller, it simply keeps no ledger, and recording is a side effect that must never be the reason a
56
+ * verdict fails to return.
57
+ *
58
+ * One helper rather than an optional-chain at each call site: the tolerance is a decision, and a
59
+ * decision spelled `?.` in two places is one nobody can find later.
60
+ */
61
+ export function noteSessionGaps(session, gaps) {
62
+ session.gaps?.note(gaps);
63
+ }
@@ -0,0 +1,79 @@
1
+ import { type InstrumentationGap } from '@reticlehq/core';
2
+ /**
3
+ * The gaps ONE action revealed, derived from facts the act path already holds.
4
+ *
5
+ * Pure, and deliberately fed by primitives rather than by the action result: every fact below is
6
+ * already computed for other reasons — `actedSource` for the red-verdict file:line, `stateUnwatched`
7
+ * because an empty `stateDiffs` would otherwise read as "the app changed nothing", the signal count
8
+ * and route movement for the causal summary. Nothing here costs a new observation. The gap surface
9
+ * is a second reading of evidence Reticle was already collecting and then discarding.
10
+ *
11
+ * ## Each kind carries its OWN gate
12
+ *
13
+ * The rule is that a gap fires only when the verdict came back weaker because of it — and "weaker"
14
+ * is different per kind, so a single global flag would be a lie in both directions:
15
+ *
16
+ * - a missing source mapping costs nothing on a PASS. The act path already says so in as many
17
+ * words: on green nobody needs the file:line and it is noise. It costs a great deal on a red,
18
+ * where it is the first thing the agent wants;
19
+ * - a missing store costs nothing unless the caller ASKED about state;
20
+ * - a silent mutation costs nothing if the app proved the outcome some other way.
21
+ *
22
+ * Encoding the gates per kind is what keeps this from degenerating into a linter that fires on every
23
+ * uninstrumented control on the page. That surface would be ignored within a day, and it would take
24
+ * the true positives with it.
25
+ */
26
+ export interface ActionInstrumentationFacts {
27
+ /** Did the verdict pass? Several gates turn on this, in different directions. */
28
+ pass: boolean;
29
+ /** True when the outcome was positively PROVED rather than inferred. */
30
+ proved?: boolean;
31
+ /**
32
+ * The `file:line` of the element this verdict is about, when the build plugin stamped one.
33
+ *
34
+ * Was a boolean, on the reasoning that the gap only needed to know WHETHER the agent could be
35
+ * pointed at code. That was true while the only gap reading it was the one that fires when the
36
+ * pointer is missing. It stopped being true the moment a gap wanted to CARRY the pointer: a gap is
37
+ * read back later, out of the ledger, by which time its `ref` is very likely dead, and a boolean
38
+ * cannot be turned back into a location. Both callers already hold this as a formatted string.
39
+ */
40
+ source?: string | undefined;
41
+ /** The ref that was driven, for the report. */
42
+ ref?: string | undefined;
43
+ /** Did the caller's predicate ask about registered state? */
44
+ stateAsked: boolean;
45
+ /** True when the session has no store registered to read. */
46
+ stateUnwatched: boolean;
47
+ /**
48
+ * Did the app declare any capabilities at all?
49
+ *
50
+ * Undefined means NOT KNOWN, and not-known must never become an accusation — callers that predate
51
+ * this field keep their behaviour rather than start reporting a gap they cannot substantiate.
52
+ */
53
+ hasCapabilities?: boolean | undefined;
54
+ /** Did the DOM move in this action's window? */
55
+ domMutated: boolean;
56
+ /** How many app signals fired in the window. */
57
+ signalsFired: number;
58
+ /** Did the route change in this action's window? */
59
+ routeChanged: boolean;
60
+ /** Did anything signal that route change? */
61
+ routeSignalFired: boolean;
62
+ /**
63
+ * Did a code change land since the last verdict with nothing declaring what it was for?
64
+ *
65
+ * Arrives already decided, because the decision needs the intent ledger and this stays pure. Its
66
+ * gate — and the reasoning that keeps it from becoming a nag — lives in `isChangeUndeclared`, and
67
+ * that is the only place allowed to answer it.
68
+ */
69
+ changeUndeclared?: boolean;
70
+ /**
71
+ * How many intents the ledger still holds undischarged. Undefined when nothing consulted it.
72
+ *
73
+ * A count rather than a boolean because the message names the number: "one thing still unproved"
74
+ * and "four things still unproved" are different situations for an agent deciding whether it is
75
+ * finished, and a bare flag makes them read the same.
76
+ */
77
+ openIntentCount?: number;
78
+ }
79
+ export declare function gapsForAction(facts: ActionInstrumentationFacts): InstrumentationGap[];
@@ -0,0 +1,77 @@
1
+ import { InstrumentationGapKind, dedupeGaps, instrumentationGap, } from '@reticlehq/core';
2
+ export function gapsForAction(facts) {
3
+ const gaps = [];
4
+ // A red verdict names the control and cannot name the line that renders it. That is the round trip
5
+ // the agent is about to spend, and the one a build plugin removes permanently.
6
+ if (!facts.pass && facts.source === undefined) {
7
+ gaps.push(instrumentationGap(InstrumentationGapKind.NO_SOURCE_MAPPING, 'the control that was driven carries no source mapping', 'this verdict can name the control but not the file and line that render it, so finding the code is a separate search', { ...(facts.ref === undefined ? {} : { ref: facts.ref }) }));
8
+ }
9
+ // Asked about state, and there is no state channel to answer from. Deliberately UNLOCATED: a store
10
+ // is registered once at app setup, nowhere near the control that was driven, and nothing in this
11
+ // window knows where that setup lives. Borrowing the acted line would send the agent to a file
12
+ // that cannot hold the fix — worse than no pointer, because it costs the trip as well.
13
+ // An app that declared NOTHING is told once, without having to be asked.
14
+ //
15
+ // The rule below fires only when an assertion about state could not be answered, which is correct
16
+ // for that gap and leaves the common case silent: an app whose capabilities file registers
17
+ // nothing drives fine, produces verdicts, and never mentions being under-instrumented — unless
18
+ // the agent happens to ask about state, which an agent with no reason to suspect a problem will
19
+ // not do. The session has known the answer the whole time and no verdict ever consulted it.
20
+ //
21
+ // Reported as the same kind, because it is the same missing thing seen from the other side, and
22
+ // guarded so the two conditions cannot report it twice.
23
+ if (false === facts.hasCapabilities || (facts.stateAsked && facts.stateUnwatched)) {
24
+ // Same missing thing, two ways of meeting it — and the sentence has to say which. Describing an
25
+ // assertion about state to an agent that made no such assertion is a false explanation, and a
26
+ // gap nobody can act on is worse than no gap: it costs the trip and teaches the wrong lesson.
27
+ const declaredNothing = false === facts.hasCapabilities;
28
+ gaps.push(instrumentationGap(InstrumentationGapKind.NO_STORE_REGISTERED, declaredNothing
29
+ ? 'this app declared no capabilities at all, so no state can be read from it'
30
+ : 'no store is registered, and this assertion was about state', declaredNothing
31
+ ? 'every verdict here rests on what the DOM happens to show, and reticle_state will stay empty however many flows are driven'
32
+ : 'the assertion could not be answered from the deterministic channel and had to fall back to what the DOM happens to show'));
33
+ }
34
+ // The app moved and said nothing, and Reticle could not prove the outcome another way.
35
+ if (facts.domMutated && 0 === facts.signalsFired && true !== facts.proved) {
36
+ gaps.push(instrumentationGap(InstrumentationGapKind.NO_SIGNAL_ON_MUTATION, 'the DOM changed and no signal fired for it', 'the outcome had to be inferred from the DOM instead of read from the app asserting its own success, which is the strongest evidence available',
37
+ // The one gap here that can be LOCATED. It is about the element that was driven, and that
38
+ // element is exactly what `source` describes — so the pointer is a fact, not a guess. It
39
+ // also outlives the `ref` beside it, which is what makes this gap still actionable when
40
+ // coverage reads it back long after the page has re-rendered.
41
+ {
42
+ ...(facts.ref === undefined ? {} : { ref: facts.ref }),
43
+ ...(facts.source === undefined ? {} : { source: facts.source }),
44
+ }));
45
+ }
46
+ // Also unlocated, for the same reason: the remedy is a router adapter wired app-wide, not a line
47
+ // at the control that happened to trigger this navigation.
48
+ if (facts.routeChanged && !facts.routeSignalFired) {
49
+ gaps.push(instrumentationGap(InstrumentationGapKind.NO_ROUTE_SIGNAL, 'the route changed and nothing signalled it', 'route consequences cannot be asserted on this app, so a navigation can only be checked by what rendered afterwards'));
50
+ }
51
+ // The one gap here that is not about the app. Deliberately UNLOCATED and deliberately generic: the
52
+ // only honest thing to report is that the intent is absent. Naming the file that changed, or the
53
+ // control that was driven, would invite the reader to treat a guess as the declaration — and a
54
+ // guessed intent reads as the developer's own words, which is strictly worse than honest absence.
55
+ if (true === facts.changeUndeclared) {
56
+ gaps.push(instrumentationGap(InstrumentationGapKind.UNDECLARED_CHANGE, 'code changed since the last verdict and no intent says what the change was for', 'this verdict can only check the app against itself, so a green here means "nothing visibly broke" rather than "the change did what it was meant to"'));
57
+ }
58
+ // A green that does not settle what the run OWES.
59
+ //
60
+ // The mirror of `changeUndeclared` above, and the more expensive half: that fires when nothing was
61
+ // declared, this when something was and no verdict ever proved it. Measured on the bench fixture —
62
+ // an agent drew SEVEN green verdicts and reported FIXED on a form that still accepted a
63
+ // whitespace-only service, quoting its own patch as the evidence. Every verdict was honestly
64
+ // green; none of them was about the thing claimed, and nothing in the result said so.
65
+ //
66
+ // Only on a PASS: a failing verdict proved nothing and already says that, so adding this would be
67
+ // two sentences for one fact. It downgrades nothing either — the assertion did hold — it just
68
+ // names the debt on the result the agent is already reading, while it is deciding if it is done.
69
+ // `proved`, not `pass`, because a bare `{ settled }` wait scores the predicate true while proving
70
+ // nothing — the verdict comes back `no-fault`, and keying on `pass` attached "this verdict passed"
71
+ // to a result whose own `because` says it is not verification.
72
+ const owed = facts.openIntentCount ?? 0;
73
+ if (facts.pass && true === facts.proved && 0 < owed) {
74
+ gaps.push(instrumentationGap(InstrumentationGapKind.INTENT_UNDISCHARGED, `this verdict passed, and ${String(owed)} declared intent(s) are still unproved`, 'a green settles what it asserted, not what the run set out to do — reporting done here rests on the change looking right rather than on anything having checked it'));
75
+ }
76
+ return dedupeGaps(gaps);
77
+ }