@reticlehq/server 2.9.0 → 2.11.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 (125) hide show
  1. package/SKILL.md +64 -26
  2. package/dist/capsule/capsule-store.d.ts +2 -2
  3. package/dist/cli/cli-parse.d.ts +1 -1
  4. package/dist/cli/cli-parse.js +1 -1
  5. package/dist/cli/cli-verify.js +1 -1
  6. package/dist/crawl/crawl.d.ts +7 -2
  7. package/dist/crawl/crawl.js +52 -9
  8. package/dist/dev/stale-issue-guard.d.ts +43 -0
  9. package/dist/dev/stale-issue-guard.js +75 -0
  10. package/dist/domain/domain-tools.js +3 -2
  11. package/dist/ee/audit-log.js +2 -2
  12. package/dist/events/body-failures.d.ts +2 -2
  13. package/dist/events/contradiction-folds.d.ts +45 -0
  14. package/dist/events/contradiction-folds.js +42 -0
  15. package/dist/events/contradictions.d.ts +19 -1
  16. package/dist/events/contradictions.js +17 -2
  17. package/dist/events/echo-mismatch.d.ts +2 -2
  18. package/dist/events/predicate-asks.d.ts +17 -0
  19. package/dist/events/predicate-asks.js +26 -0
  20. package/dist/events/predicate-eval.js +211 -24
  21. package/dist/events/predicate-parse.js +9 -3
  22. package/dist/events/predicate-schema.d.ts +18 -0
  23. package/dist/events/predicate-schema.js +55 -0
  24. package/dist/events/stale-response.d.ts +2 -3
  25. package/dist/events/stale-response.js +49 -2
  26. package/dist/events/unit-mismatch.d.ts +2 -2
  27. package/dist/flows/flow-tools.js +34 -5
  28. package/dist/flows/predicate-to-expect.js +8 -0
  29. package/dist/flows/verify-change-tools.js +6 -2
  30. package/dist/honesty/blind-spots.d.ts +9 -0
  31. package/dist/honesty/blind-spots.js +17 -4
  32. package/dist/honesty/gap-ledger.d.ts +52 -0
  33. package/dist/honesty/gap-ledger.js +63 -0
  34. package/dist/honesty/instrumentation-gaps.d.ts +55 -0
  35. package/dist/honesty/instrumentation-gaps.js +21 -0
  36. package/dist/honesty/observability.d.ts +37 -0
  37. package/dist/honesty/observability.js +40 -0
  38. package/dist/honesty/verified.js +16 -0
  39. package/dist/index.d.ts +18 -1
  40. package/dist/index.js +57 -0
  41. package/dist/init/mcp.d.ts +1 -2
  42. package/dist/init/mcp.js +4 -1
  43. package/dist/init/node-io.js +3 -0
  44. package/dist/init/run.d.ts +18 -0
  45. package/dist/init/run.js +34 -3
  46. package/dist/init/slash-command.d.ts +1 -1
  47. package/dist/init/slash-command.js +33 -10
  48. package/dist/journal/envelope.d.ts +24 -24
  49. package/dist/license/license.d.ts +76 -5
  50. package/dist/license/license.js +93 -29
  51. package/dist/mcp/mcp-proxy.d.ts +2 -27
  52. package/dist/mcp/mcp-proxy.js +4 -88
  53. package/dist/mcp/mcp.d.ts +18 -2
  54. package/dist/mcp/mcp.js +31 -10
  55. package/dist/mcp/proxy-daemon-probe.d.ts +8 -0
  56. package/dist/mcp/proxy-daemon-probe.js +48 -0
  57. package/dist/mcp/proxy-handshake.js +1 -1
  58. package/dist/mcp/server-instructions.js +2 -2
  59. package/dist/mcp/sse-frame-parser.d.ts +19 -0
  60. package/dist/mcp/sse-frame-parser.js +41 -0
  61. package/dist/project/artifact-root.d.ts +83 -0
  62. package/dist/project/artifact-root.js +112 -0
  63. package/dist/project/memory-fs.d.ts +9 -0
  64. package/dist/project/memory-fs.js +88 -0
  65. package/dist/project/project-store.d.ts +17 -0
  66. package/dist/project/project-store.js +33 -0
  67. package/dist/project/remember-project.d.ts +24 -0
  68. package/dist/project/remember-project.js +40 -0
  69. package/dist/project/session-root.d.ts +15 -0
  70. package/dist/project/session-root.js +31 -0
  71. package/dist/runs/run-tools.js +2 -1
  72. package/dist/session/lease-availability.d.ts +40 -0
  73. package/dist/session/lease-availability.js +43 -0
  74. package/dist/session/no-session-diagnosis.d.ts +17 -0
  75. package/dist/session/no-session-diagnosis.js +41 -8
  76. package/dist/session/no-session-watch.js +7 -0
  77. package/dist/session/session-readiness.js +1 -1
  78. package/dist/session/session.d.ts +9 -0
  79. package/dist/session/session.js +9 -0
  80. package/dist/session/stall-clock.d.ts +18 -0
  81. package/dist/session/stall-clock.js +48 -0
  82. package/dist/telemetry/daemon-telemetry.js +16 -22
  83. package/dist/telemetry/license-activation.d.ts +48 -0
  84. package/dist/telemetry/license-activation.js +57 -0
  85. package/dist/telemetry/session-metrics.d.ts +1 -1
  86. package/dist/telemetry/session-metrics.js +1 -1
  87. package/dist/telemetry/telemetry.d.ts +12 -3
  88. package/dist/telemetry/telemetry.js +34 -5
  89. package/dist/tools/act-tools.js +34 -4
  90. package/dist/tools/assert-verdict.d.ts +2 -0
  91. package/dist/tools/assert-verdict.js +24 -1
  92. package/dist/tools/contract-tools.js +5 -3
  93. package/dist/tools/coverage-tools.js +33 -0
  94. package/dist/tools/dynamic-tools.js +1 -1
  95. package/dist/tools/error-recovery.js +1 -1
  96. package/dist/tools/feedback-tools.js +31 -6
  97. package/dist/tools/invoke-tool.d.ts +1 -1
  98. package/dist/tools/invoke-tool.js +10 -5
  99. package/dist/tools/observe-tools.js +8 -2
  100. package/dist/tools/tool-kit.d.ts +46 -4
  101. package/dist/tools/tool-names.d.ts +2 -0
  102. package/dist/tools/tool-names.js +2 -0
  103. package/dist/tools/tool-surface.js +2 -3
  104. package/dist/tools/tools.js +16 -1
  105. package/docs/README.md +1 -0
  106. package/docs/agent-cheatsheet.md +2 -2
  107. package/docs/capabilities.mdx +4 -4
  108. package/docs/cli/hunt.mdx +2 -2
  109. package/docs/deploy-checks.md +2 -2
  110. package/docs/docs.json +2 -2
  111. package/docs/enterprise.md +9 -3
  112. package/docs/flows.md +4 -4
  113. package/docs/gates.md +1 -1
  114. package/docs/license-activation.mdx +145 -0
  115. package/docs/recipes.mdx +2 -2
  116. package/docs/telemetry-contract.md +18 -12
  117. package/docs/telemetry-events.mdx +1 -34
  118. package/docs/telemetry.md +0 -1
  119. package/docs/tools-overview.mdx +1 -1
  120. package/docs/tools-session-and-feedback.mdx +5 -0
  121. package/docs/tools-tools-and-run.mdx +15 -15
  122. package/docs/usage.md +3 -3
  123. package/package.json +2 -2
  124. package/dist/telemetry/instrumentation-stall.d.ts +0 -50
  125. package/dist/telemetry/instrumentation-stall.js +0 -90
@@ -0,0 +1,42 @@
1
+ import { log } from '../log.js';
2
+ const FOLDS = [];
3
+ /**
4
+ * Add a rule to every verdict this engine produces. Returns the undo.
5
+ *
6
+ * The undo is not a convenience: without it a registry is process-global state that tests cannot
7
+ * clean up, so each one starts depending on the order it ran in.
8
+ */
9
+ export function registerContradictionFold(fold) {
10
+ FOLDS.push(fold);
11
+ return () => {
12
+ const at = FOLDS.indexOf(fold);
13
+ if (at !== -1)
14
+ FOLDS.splice(at, 1);
15
+ };
16
+ }
17
+ /** What is registered right now. Exported so a guard can assert the registry is empty by default. */
18
+ export function registeredContradictionFolds() {
19
+ return [...FOLDS];
20
+ }
21
+ /**
22
+ * Run every registered rule, containing anything one of them throws.
23
+ *
24
+ * A registered fold is somebody else's code running inside every verdict path this package has. If a
25
+ * throw could propagate, one defect in a consumer's rule would take down `assert`, `act_and_wait`,
26
+ * `observe` and `crawl` at once — turning a missing finding into a dead engine. Contained and logged:
27
+ * the rules that did run still report, and the failure is visible rather than swallowed.
28
+ */
29
+ export function runRegisteredFolds(events, options) {
30
+ const found = [];
31
+ for (const fold of FOLDS) {
32
+ try {
33
+ found.push(...fold(events, options));
34
+ }
35
+ catch (error) {
36
+ log('contradiction_fold_failed', {
37
+ error: error instanceof Error ? error.message : String(error),
38
+ });
39
+ }
40
+ }
41
+ return found;
42
+ }
@@ -19,7 +19,16 @@ import { type DeclaredNetFailure } from './declared.js';
19
19
  * Pure: a window of events in, findings out. No session, no IO, no clock.
20
20
  */
21
21
  export interface Contradiction {
22
- kind: ContradictionKind;
22
+ /**
23
+ * Typed `string`, not `ContradictionKind`, because the vocabulary is open at the EDGE.
24
+ *
25
+ * `ContradictionKind` enumerates what THIS package's rules emit, and every one of them is still
26
+ * checked against it — see `OwnContradiction`, which is what the folds below build. A rule
27
+ * registered by a consumer emits kinds this package has never heard of and must not have to add
28
+ * them here to be reportable: a shared enum is exactly how a consumer's private vocabulary ends up
29
+ * shipped in the free product by accident.
30
+ */
31
+ kind: string;
23
32
  /** What one channel asserted — the optimistic half. */
24
33
  claim: string;
25
34
  /** What the other channel asserted — the half that contradicts it. */
@@ -27,6 +36,15 @@ export interface Contradiction {
27
36
  /** Concrete evidence, so the agent can go straight to the call or the control. */
28
37
  detail: string;
29
38
  }
39
+ /**
40
+ * A contradiction emitted by one of THIS package's rules — the kind is closed.
41
+ *
42
+ * Widening `Contradiction.kind` to `string` for the consumer seam would otherwise have made every
43
+ * emit site below accept a typo'd literal. This keeps them checked without closing the edge.
44
+ */
45
+ export type OwnContradiction = Contradiction & {
46
+ kind: ContradictionKind;
47
+ };
30
48
  export interface ContradictionOptions {
31
49
  /** The action that opened this window, when one did. Enables the no-effect check. */
32
50
  action?: string | undefined;
@@ -5,6 +5,7 @@ import { findEchoMismatches } from './echo-mismatch.js';
5
5
  import { findUnitMismatches } from './unit-mismatch.js';
6
6
  import { asNumber, asString } from '../tools/tools-helpers.js';
7
7
  import { matchesDeclaredFailure } from './declared.js';
8
+ import { runRegisteredFolds } from './contradiction-folds.js';
8
9
  function netCall(e) {
9
10
  const status = asNumber(e.data['status']);
10
11
  return {
@@ -255,7 +256,19 @@ export function findContradictions(allEvents, options = {}) {
255
256
  // claimed success" about an app that plainly reported a failure is true in outline and wrong in
256
257
  // its reasoning, which is how a checker stops being believed.
257
258
  const successSignals = signals.filter((name) => !ACKNOWLEDGED.test(name));
258
- if (failed.length > 0 && successSignals.length > 0) {
259
+ // An app that RETRACTED has not claimed success, whenever it fired the optimistic signal.
260
+ //
261
+ // The weaker UI rule below already consulted this and the sharper signal rule did not, so an app
262
+ // that announced "ack:requested", met a 500, and then correctly emitted "ack:failed" and rolled the
263
+ // row back was reported as explicitly asserting success — the strongest accusation this file makes,
264
+ // against code doing exactly the right thing. On a fixture suite the scenario's FIXED twin produced
265
+ // the same finding as the build that swallowed the failure, which makes the finding worthless on
266
+ // the one measurement that scores precision.
267
+ //
268
+ // Ordering cannot decide this: an optimistic UI legitimately fires its success signal BEFORE the
269
+ // response, so "the claim must follow the failure" would miss the real defect. What separates them
270
+ // is not when the app spoke, it is whether it took it back.
271
+ if (failed.length > 0 && successSignals.length > 0 && !failureAcknowledged(events)) {
259
272
  found.push({
260
273
  kind: ContradictionKind.SIGNAL_CONTRADICTED,
261
274
  claim: `the app fired ${successSignals.map((s) => `"${s}"`).join(', ')}`,
@@ -341,5 +354,7 @@ export function findContradictions(allEvents, options = {}) {
341
354
  });
342
355
  }
343
356
  }
344
- return found;
357
+ // Consumer rules run LAST and over the same app-only window, so a service embedding this engine
358
+ // adds to the verdict rather than forking the file that produces it.
359
+ return [...found, ...runRegisteredFolds(events, options)];
345
360
  }
@@ -1,5 +1,5 @@
1
1
  import { type ReticleEvent } from '@reticlehq/core';
2
- import type { Contradiction } from './contradictions.js';
2
+ import type { OwnContradiction } from './contradictions.js';
3
3
  /**
4
4
  * Contradictions for writes whose response echoes a different value than the request asked for.
5
5
  *
@@ -7,4 +7,4 @@ import type { Contradiction } from './contradictions.js';
7
7
  * action that opened this window was dispatched. A finding about a request that had already settled
8
8
  * before the agent acted otherwise reads as a verdict on the change it just made.
9
9
  */
10
- export declare function findEchoMismatches(events: readonly ReticleEvent[], actionSince?: number): Contradiction[];
10
+ export declare function findEchoMismatches(events: readonly ReticleEvent[], actionSince?: number): OwnContradiction[];
@@ -0,0 +1,17 @@
1
+ import type { Predicate } from './predicate-schema.js';
2
+ /**
3
+ * Did the caller's predicate ask about registered state?
4
+ *
5
+ * Walks composites, because `allOf[{net}, {state}]` asks about state exactly as much as a bare
6
+ * `{state}` does — and the gap that reveals is the same one either way.
7
+ *
8
+ * Lives here rather than at a call site because both the act path and the assert path need it, and
9
+ * two walkers over one predicate tree is two chances to disagree about what counts as asking.
10
+ *
11
+ * NOT in `predicate-schema.ts` next to `predicateFieldsFor`, where it would otherwise belong: five
12
+ * open contributor PRs are queued on that file and on `predicate-eval.ts`, and a one-function import
13
+ * is not worth handing five people a conflict. Named `predicate-asks` rather than `predicate-shape`
14
+ * because `predicate-shape.test.ts` already exists and is about something else entirely — a file
15
+ * that reads as the subject of an unrelated test is a half-hour somebody loses later.
16
+ */
17
+ export declare function declaresState(predicate: Predicate): boolean;
@@ -0,0 +1,26 @@
1
+ import { PredicateKind } from '@reticlehq/core';
2
+ /**
3
+ * Did the caller's predicate ask about registered state?
4
+ *
5
+ * Walks composites, because `allOf[{net}, {state}]` asks about state exactly as much as a bare
6
+ * `{state}` does — and the gap that reveals is the same one either way.
7
+ *
8
+ * Lives here rather than at a call site because both the act path and the assert path need it, and
9
+ * two walkers over one predicate tree is two chances to disagree about what counts as asking.
10
+ *
11
+ * NOT in `predicate-schema.ts` next to `predicateFieldsFor`, where it would otherwise belong: five
12
+ * open contributor PRs are queued on that file and on `predicate-eval.ts`, and a one-function import
13
+ * is not worth handing five people a conflict. Named `predicate-asks` rather than `predicate-shape`
14
+ * because `predicate-shape.test.ts` already exists and is about something else entirely — a file
15
+ * that reads as the subject of an unrelated test is a half-hour somebody loses later.
16
+ */
17
+ export function declaresState(predicate) {
18
+ if (predicate.kind === PredicateKind.STATE)
19
+ return true;
20
+ if (predicate.kind === PredicateKind.ALL_OF || predicate.kind === PredicateKind.ANY_OF) {
21
+ return predicate.predicates.some(declaresState);
22
+ }
23
+ if (predicate.kind === PredicateKind.NOT)
24
+ return declaresState(predicate.predicate);
25
+ return false;
26
+ }
@@ -160,8 +160,111 @@ function describeNetFilter(p) {
160
160
  const { kind: _kind, count: _count, since: _since, ...filter } = p;
161
161
  return JSON.stringify(filter);
162
162
  }
163
+ /**
164
+ * URL suffixes only the DOCUMENT fetches, which the network observer therefore never records.
165
+ *
166
+ * `network.ts` patches `fetch` and `XMLHttpRequest` and nothing else, so `<link rel=icon>`,
167
+ * `<link rel=manifest>`, stylesheets, fonts and `<img src>` are invisible to it. "No matching call"
168
+ * and "that class of call is not observed" are then indistinguishable, and the miss graded
169
+ * `assertion_failed` — a false RED. Reported from the field: an assert over `/favicon.ico`,
170
+ * `/site.webmanifest` and `/apple-touch-icon.png` returned `verified:"no"` while curl showed all
171
+ * three answering 200. A false negative here is worse than an unknown, because an agent that trusts
172
+ * it goes and "fixes" working code.
173
+ *
174
+ * Deliberately NOT here: `.js` and `.json`. Both are routinely fetched via `fetch`/XHR — a module
175
+ * preload and an API call can share a suffix — so downgrading them would hide real misses. This list
176
+ * is only the suffixes for which the document is the sole plausible initiator.
177
+ *
178
+ * This began as the smaller half of #447: it did not make these requests observable, it stopped
179
+ * Reticle claiming they did not happen. The other half, observing them via resource timing, has
180
+ * since landed in the browser SDK, so the two halves are no longer independent and this downgrade is
181
+ * now GATED: when the event stream carries at least one document-initiated record, the observer is
182
+ * demonstrably live and a miss over these suffixes is real evidence, graded a plain failure. Only a
183
+ * page whose stream holds no resource entry at all keeps the honest "cannot tell apart" answer,
184
+ * because there the observer may simply not exist.
185
+ */
186
+ const DOCUMENT_ONLY_SUFFIXES = [
187
+ '.ico',
188
+ '.png',
189
+ '.jpg',
190
+ '.jpeg',
191
+ '.gif',
192
+ '.svg',
193
+ '.webp',
194
+ '.avif',
195
+ '.bmp',
196
+ '.css',
197
+ '.woff',
198
+ '.woff2',
199
+ '.ttf',
200
+ '.otf',
201
+ '.eot',
202
+ '.webmanifest',
203
+ ];
204
+ /**
205
+ * Does this filter target a class of request the observer cannot see?
206
+ *
207
+ * Read off `urlContains` only, and only when the pattern ENDS in one of the suffixes — a filter of
208
+ * `/api/` that happens to contain `.css` somewhere in a query string is still an ordinary XHR
209
+ * target. Query strings and fragments are stripped first, since `favicon.ico?v=2` is the same asset.
210
+ */
211
+ function targetsUnobservedChannel(p) {
212
+ if (p.urlContains === undefined)
213
+ return false;
214
+ const path = (p.urlContains.split('#')[0] ?? '').split('?')[0]?.toLowerCase() ?? '';
215
+ return DOCUMENT_ONLY_SUFFIXES.some((suffix) => path.endsWith(suffix));
216
+ }
217
+ /**
218
+ * Initiator types the browser SDK's resource-timing observer stamps onto document-initiated records
219
+ * (packages/browser/src/observers/network.ts). The patched transports sign themselves `fetch`,
220
+ * `xhr` or `beacon`, so any NET_REQUEST carrying one of these came from a `resource` entry, and its
221
+ * mere presence is proof the PerformanceObserver is alive on this page. Mirrors the wire; keep in
222
+ * sync with the browser package.
223
+ */
224
+ const DOCUMENT_INITIATORS = new Set([
225
+ 'link',
226
+ 'css',
227
+ 'img',
228
+ 'script',
229
+ 'manifest',
230
+ 'other',
231
+ ]);
232
+ /**
233
+ * Did the observer actually report a document-initiated load?
234
+ *
235
+ * This is the gate on the unobserved-channel downgrade above. Once subresource observation landed,
236
+ * a miss over `.ico`/`.css`/`.woff2` is no longer inherently unknowable: if the observer is live it
237
+ * would have seen the favicon load, so seeing none is evidence it never fired. But the observer is
238
+ * opt-in-by-engine, not guaranteed: on a renderer without `PerformanceObserver` (or before any
239
+ * resource entry exists) nothing distinguishes "not requested" from "not visible", and the honest
240
+ * answer stays inconclusive. Liveness is inferred from the same events being judged: one
241
+ * document-initiated record anywhere in the window proves the channel works.
242
+ */
243
+ function observerSawSubresources(events) {
244
+ return events.some((e) => {
245
+ if (e.type !== EventType.NET_REQUEST)
246
+ return false;
247
+ const initiator = str(e.data['initiator']);
248
+ return initiator !== undefined && DOCUMENT_INITIATORS.has(initiator);
249
+ });
250
+ }
251
+ /** The sentence both zero-match branches hand to `inconclusive`. */
252
+ function unobservedChannelReason(p) {
253
+ return (`no call matched ${describeNetFilter(p)}, and no document-initiated load was recorded either, ` +
254
+ `so this page exposed no resource timing to read: Reticle observes fetch and XMLHttpRequest ` +
255
+ `directly, while a request the document initiates (<link rel=icon|manifest|preload>, a ` +
256
+ `stylesheet, a font, <img src>) leaves no record, and that state cannot be told apart from the ` +
257
+ `request having been made. ` +
258
+ `Nothing here says the app is wrong. Check it outside the browser, or assert something the ` +
259
+ `document does not fetch on its own`);
260
+ }
163
261
  export function evalNet(events, p) {
164
262
  const since = p.since ?? 0;
263
+ /**
264
+ * Computed once: the gate every zero-match downgrade below reads. True means the resource-timing
265
+ * observer is demonstrably live in this window, so a miss is evidence rather than blindness.
266
+ */
267
+ const sawSubresources = observerSawSubresources(events);
165
268
  /**
166
269
  * Did any call match everything EXCEPT the body assertion, while carrying no recorded body?
167
270
  *
@@ -171,6 +274,8 @@ export function evalNet(events, p) {
171
274
  * because it is the same pass over the same events.
172
275
  */
173
276
  let matchedButUnrecorded = false;
277
+ /** A call matched url/method but carried NO readable status (document-initiated subresource). */
278
+ let unobservableStatus = false;
174
279
  /**
175
280
  * The response body of a call that matched everything EXCEPT the body assertion, and HAD one.
176
281
  *
@@ -190,8 +295,19 @@ export function evalNet(events, p) {
190
295
  if (p.urlContains !== undefined && !(str(d['url']) ?? '').includes(p.urlContains)) {
191
296
  return false;
192
297
  }
193
- if (p.status !== undefined && num(d['status']) !== p.status)
194
- return false;
298
+ if (p.status !== undefined) {
299
+ const status = num(d['status']);
300
+ if (status === undefined) {
301
+ // Document-initiated subresources (link/css/img/manifest via resource timing) carry no
302
+ // readable status on engines without responseStatus. A failed assertion here would read
303
+ // "your change is broken" and send the caller to fix working code — the exact false
304
+ // negative the oracle exists to prevent. Downgrade to unknown instead.
305
+ unobservableStatus = true;
306
+ return false;
307
+ }
308
+ if (status !== p.status)
309
+ return false;
310
+ }
195
311
  if (p.ok !== undefined && callSucceeded(d) !== p.ok)
196
312
  return false;
197
313
  if (p.bodyContains !== undefined) {
@@ -211,6 +327,18 @@ export function evalNet(events, p) {
211
327
  }
212
328
  return true;
213
329
  });
330
+ if (unobservableStatus && 0 === matches.length) {
331
+ // The url/method matched a document-initiated subresource whose engine could not read a status.
332
+ // "No matching call" would be a lie in both directions: it may have succeeded, it may have 404'd
333
+ // on exactly the path mistake the caller is hunting. Say the truth — not observable here.
334
+ return {
335
+ pass: false,
336
+ inconclusive: `a document-initiated request matching ${describeNetFilter(p)} was observed, but this engine does not expose its status code (resource timing without responseStatus) — assert on the element or route instead, or check the network tab`,
337
+ observed: 'a matching request with no readable status',
338
+ expected: `a status of ${String(p.status)} on ${JSON.stringify(p.urlContains ?? '*')}`,
339
+ assertion: 'net.unobservable-status',
340
+ };
341
+ }
214
342
  if (matchedButUnrecorded && 0 === matches.length) {
215
343
  return {
216
344
  pass: false,
@@ -237,19 +365,37 @@ export function evalNet(events, p) {
237
365
  if (p.count !== undefined) {
238
366
  return matches.length === p.count
239
367
  ? { pass: true, evidence: { matched: matches.length } }
240
- : {
241
- pass: false,
242
- // Monotonic: more matches than asked for can never become exactly the number asked for.
243
- // This is the double-submit — two writes 59ms apart — so the finding that matters most was
244
- // also the one that took the caller's whole budget to report.
245
- ...(matches.length > p.count ? { decided: true } : {}),
246
- failureReason: `expected ${String(p.count)} network call(s) matching ${describeNetFilter(p)}, saw ${String(matches.length)}`,
247
- observed: `${String(matches.length)} matching network call(s)`,
248
- expected: `exactly ${String(p.count)} matching ${describeNetFilter(p)}`,
249
- assertion: 'net.count',
250
- };
368
+ : 0 === matches.length && targetsUnobservedChannel(p) && !sawSubresources
369
+ ? {
370
+ pass: false,
371
+ failureReason: unobservedChannelReason(p),
372
+ inconclusive: unobservedChannelReason(p),
373
+ assertion: 'net.count',
374
+ }
375
+ : {
376
+ pass: false,
377
+ // Monotonic: more matches than asked for can never become exactly the number asked for.
378
+ // This is the double-submit — two writes 59ms apart — so the finding that matters most was
379
+ // also the one that took the caller's whole budget to report.
380
+ ...(matches.length > p.count ? { decided: true } : {}),
381
+ failureReason: `expected ${String(p.count)} network call(s) matching ${describeNetFilter(p)}, saw ${String(matches.length)}`,
382
+ observed: `${String(matches.length)} matching network call(s)`,
383
+ expected: `exactly ${String(p.count)} matching ${describeNetFilter(p)}`,
384
+ assertion: 'net.count',
385
+ };
251
386
  }
252
387
  const hit = matches[0];
388
+ if (hit === undefined && targetsUnobservedChannel(p) && !sawSubresources) {
389
+ const reason = unobservedChannelReason(p);
390
+ return {
391
+ pass: false,
392
+ failureReason: reason,
393
+ inconclusive: reason,
394
+ observed: describeObserved('calls', events.filter((e) => e.type === EventType.NET_REQUEST).map(describeCall)),
395
+ expected: `at least one call matching ${describeNetFilter(p)}`,
396
+ assertion: 'net.unobserved-channel',
397
+ };
398
+ }
253
399
  return hit !== undefined
254
400
  ? { pass: true, evidence: hit.data }
255
401
  : {
@@ -296,25 +442,66 @@ export function evalConsole(events, p) {
296
442
  return isErr;
297
443
  return e.type === CONSOLE_LEVEL_TYPE[p.level];
298
444
  });
445
+ // A text match narrows the population to entries whose captured message contains the substring.
446
+ // With `absent: true` this is the whole point: "THIS message did not appear", not "no messages
447
+ // appeared" — the difference between a regression check and a fragile one that any unrelated
448
+ // warning anywhere in the app breaks.
449
+ const wanted = 'contains' in p ? p.contains : undefined;
450
+ // Captured messages are strings (stringifyArgs in the browser observer), but a malformed or
451
+ // foreign event must not crash the evaluator: non-strings stringify defensively, and objects
452
+ // go through JSON.stringify rather than a default toString that would print '[object Object]'.
453
+ const asText = (v) => {
454
+ if ('string' === typeof v)
455
+ return v;
456
+ try {
457
+ return JSON.stringify(v) ?? '';
458
+ }
459
+ catch {
460
+ return '';
461
+ }
462
+ };
463
+ const matching = wanted !== undefined
464
+ ? matches.filter((e) => asText(e.data['message']).includes(wanted))
465
+ : matches;
299
466
  if (true === p.absent) {
300
- return 0 === matches.length
467
+ if (wanted !== undefined && 0 === matching.length && matches.length > 0) {
468
+ // Other entries exist but none carries the substring: exactly the pass an absence-with-match
469
+ // asserts. Name both counts so the caller can tell this from a silent window.
470
+ return {
471
+ pass: true,
472
+ evidence: { absent: true, contains: wanted },
473
+ };
474
+ }
475
+ return 0 === matching.length
301
476
  ? { pass: true, evidence: { absent: true } }
302
477
  : {
303
478
  pass: false,
304
- failureReason: `expected no ${p.level ?? 'console'} entries but found ${String(matches.length)}`,
305
- observed: `${String(matches.length)} ${p.level ?? 'console'} entr${1 === matches.length ? 'y' : 'ies'}`,
306
- expected: `no ${p.level ?? 'console'} entries`,
307
- assertion: 'console.absent',
308
- evidence: matches.map((e) => e.data),
479
+ failureReason: wanted !== undefined
480
+ ? `expected no ${p.level ?? 'console'} entry containing ${JSON.stringify(wanted)} but found ${String(matching.length)}`
481
+ : `expected no ${p.level ?? 'console'} entries but found ${String(matches.length)}`,
482
+ observed: wanted !== undefined
483
+ ? `${String(matching.length)} ${p.level ?? 'console'} entr${1 === matching.length ? 'y' : 'ies'} containing ${JSON.stringify(wanted)}`
484
+ : `${String(matches.length)} ${p.level ?? 'console'} entr${1 === matches.length ? 'y' : 'ies'}`,
485
+ expected: wanted !== undefined
486
+ ? `no ${p.level ?? 'console'} entry containing ${JSON.stringify(wanted)}`
487
+ : `no ${p.level ?? 'console'} entries`,
488
+ assertion: wanted !== undefined ? 'console.absent-contains' : 'console.absent',
489
+ evidence: matching.map((e) => e.data),
309
490
  };
310
491
  }
311
- return matches.length > 0
312
- ? { pass: true, evidence: matches.map((e) => e.data) }
492
+ return matching.length > 0
493
+ ? { pass: true, evidence: matching.map((e) => e.data) }
313
494
  : {
314
495
  pass: false,
315
- failureReason: `no ${p.level ?? 'console'} entries found`,
316
- observed: `no ${p.level ?? 'console'} entries in the window`,
317
- expected: `at least one ${p.level ?? 'console'} entry`,
496
+ failureReason: wanted !== undefined
497
+ ? `no ${p.level ?? 'console'} entry containing ${JSON.stringify(wanted)} found`
498
+ : `no ${p.level ?? 'console'} entries found`,
499
+ observed: wanted !== undefined
500
+ ? `no ${p.level ?? 'console'} entry containing ${JSON.stringify(wanted)} in the window`
501
+ : `no ${p.level ?? 'console'} entries in the window`,
502
+ expected: wanted !== undefined
503
+ ? `at least one ${p.level ?? 'console'} entry containing ${JSON.stringify(wanted)}`
504
+ : `at least one ${p.level ?? 'console'} entry`,
318
505
  assertion: 'console.present',
319
506
  };
320
507
  }
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { z } from 'zod';
16
16
  import { PredicateKind } from '@reticlehq/core';
17
- import { PredicateSchema, predicateFieldsFor } from './predicate-eval.js';
17
+ import { PredicateSchema, predicateFieldsFor, predicateNestedFieldsFor } from './predicate-eval.js';
18
18
  /**
19
19
  * One valid call PER KIND, because an example of another kind answers a question nobody asked.
20
20
  *
@@ -90,7 +90,13 @@ export function parsePredicate(input) {
90
90
  */
91
91
  function accepted(kind) {
92
92
  const fields = predicateFieldsFor(kind);
93
- if (0 < fields.length)
94
- return `${kind} accepts: ${fields.join(', ')}.`;
93
+ if (0 < fields.length) {
94
+ // A field whose value is an object is the one an agent cannot guess from its name alone, so
95
+ // expand it in the same breath rather than making the shape a second round trip.
96
+ const nested = Object.entries(predicateNestedFieldsFor(kind))
97
+ .map(([field, keys]) => ` ${field} accepts: ${keys.join(', ')}.`)
98
+ .join('');
99
+ return `${kind} accepts: ${fields.join(', ')}.${nested}`;
100
+ }
95
101
  return `"${kind}" is not a predicate kind — use one of: ${Object.values(PredicateKind).join(', ')}.`;
96
102
  }
@@ -38,6 +38,12 @@ export type Predicate = {
38
38
  } | {
39
39
  kind: typeof PredicateKind.CONSOLE;
40
40
  level?: string;
41
+ /**
42
+ * A substring the captured message must contain. With `absent: true` this flips the meaning
43
+ * from "no console entries at all" to "THIS message did not appear" — the assertion people
44
+ * actually write regression checks for (deprecation warnings, no-op handler notices).
45
+ */
46
+ contains?: string;
41
47
  absent?: boolean;
42
48
  since?: number;
43
49
  } | {
@@ -97,3 +103,15 @@ export declare const PredicateSchema: z.ZodType<Predicate>;
97
103
  * wall. Empty for a kind that is not in the union.
98
104
  */
99
105
  export declare function predicateFieldsFor(kind: string): readonly string[];
106
+ /**
107
+ * One level into the fields that are themselves objects — `{ query: ['by', 'value', ...] }`.
108
+ *
109
+ * Naming the top-level fields alone leaves `element` unguessable: `query` is an object with its own
110
+ * required shape, and a plain CSS string works in `reticle_snapshot.scope` and `reticle_query.scope`,
111
+ * so assuming it works here is the natural guess. The rejection is the only place that inconsistency
112
+ * can be explained.
113
+ *
114
+ * One level only. Derived from the schema for the same reason `predicateFieldsFor` is: a stale field
115
+ * list is worse than none, because the agent trusts it and retries into the same wall.
116
+ */
117
+ export declare function predicateNestedFieldsFor(kind: string): Readonly<Record<string, readonly string[]>>;
@@ -21,6 +21,10 @@ const PREDICATE_ALIASES = {
21
21
  // most common thing an agent reaches for here.
22
22
  [PredicateKind.ROUTE]: { path: 'pathname', urlContains: 'contains', url: 'contains' },
23
23
  [PredicateKind.NET]: { url: 'urlContains' },
24
+ // `textContains` on a `console` predicate: the reporter who hit the missing matcher reached for
25
+ // it first, by analogy with `net { urlContains }` — and that parallel is exactly right, since
26
+ // both mean "this entry must contain this substring". Accepted as an alias for `contains`.
27
+ [PredicateKind.CONSOLE]: { textContains: 'contains' },
24
28
  [PredicateKind.SIGNAL]: { data: 'dataMatches' },
25
29
  // `of` on a composite: it reads naturally, several assertion libraries spell it that way, and it has
26
30
  // no other meaning here. Observed twice in one drive on a real app, and each rejection cost a round
@@ -246,6 +250,7 @@ function predicateUnion() {
246
250
  .object({
247
251
  kind: z.literal(PredicateKind.CONSOLE),
248
252
  level: z.string().optional(),
253
+ contains: z.string().min(1).optional(),
249
254
  absent: z.boolean().optional(),
250
255
  since: z.number().optional(),
251
256
  })
@@ -307,3 +312,53 @@ export function predicateFieldsFor(kind) {
307
312
  }
308
313
  return [];
309
314
  }
315
+ /** Peel optional/nullable/default/effects wrappers off a field to reach the schema underneath. */
316
+ function unwrapSchema(schema) {
317
+ let current = schema;
318
+ for (;;) {
319
+ if (current instanceof z.ZodOptional ||
320
+ current instanceof z.ZodNullable ||
321
+ current instanceof z.ZodDefault) {
322
+ current = current._def.innerType;
323
+ continue;
324
+ }
325
+ if (current instanceof z.ZodEffects) {
326
+ current = current._def.schema;
327
+ continue;
328
+ }
329
+ return current;
330
+ }
331
+ }
332
+ /**
333
+ * One level into the fields that are themselves objects — `{ query: ['by', 'value', ...] }`.
334
+ *
335
+ * Naming the top-level fields alone leaves `element` unguessable: `query` is an object with its own
336
+ * required shape, and a plain CSS string works in `reticle_snapshot.scope` and `reticle_query.scope`,
337
+ * so assuming it works here is the natural guess. The rejection is the only place that inconsistency
338
+ * can be explained.
339
+ *
340
+ * One level only. Derived from the schema for the same reason `predicateFieldsFor` is: a stale field
341
+ * list is worse than none, because the agent trusts it and retries into the same wall.
342
+ */
343
+ export function predicateNestedFieldsFor(kind) {
344
+ for (const option of predicateUnion().options) {
345
+ const literal = option.shape['kind'];
346
+ if (!(literal instanceof z.ZodLiteral) || literal.value !== kind)
347
+ continue;
348
+ const nested = {};
349
+ for (const [field, schema] of Object.entries(option.shape)) {
350
+ if ('kind' === field)
351
+ continue;
352
+ const inner = unwrapSchema(schema);
353
+ // `instanceof z.ZodObject` narrows to ZodObject<any>, whose `.shape` is `any`. Name the shape
354
+ // type so the keys are read off something typed rather than laundering an `any` through
355
+ // Object.keys.
356
+ if (inner instanceof z.ZodObject) {
357
+ const shape = inner.shape;
358
+ nested[field] = Object.keys(shape);
359
+ }
360
+ }
361
+ return nested;
362
+ }
363
+ return {};
364
+ }
@@ -1,4 +1,3 @@
1
1
  import { type ReticleEvent } from '@reticlehq/core';
2
- import type { Contradiction } from './contradictions.js';
3
- /** The one contradiction, or none. Reported once per window — a race is a fact about the window. */
4
- export declare function findStaleResponses(events: readonly ReticleEvent[]): Contradiction[];
2
+ import type { OwnContradiction } from './contradictions.js';
3
+ export declare function findStaleResponses(events: readonly ReticleEvent[]): OwnContradiction[];