@reticlehq/server 2.10.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 (73) hide show
  1. package/SKILL.md +47 -9
  2. package/dist/dev/stale-issue-guard.d.ts +43 -0
  3. package/dist/dev/stale-issue-guard.js +75 -0
  4. package/dist/domain/domain-tools.js +3 -2
  5. package/dist/events/predicate-asks.d.ts +17 -0
  6. package/dist/events/predicate-asks.js +26 -0
  7. package/dist/events/predicate-eval.js +211 -24
  8. package/dist/events/predicate-parse.js +9 -3
  9. package/dist/events/predicate-schema.d.ts +18 -0
  10. package/dist/events/predicate-schema.js +55 -0
  11. package/dist/flows/flow-tools.js +33 -4
  12. package/dist/flows/predicate-to-expect.js +8 -0
  13. package/dist/flows/verify-change-tools.js +5 -1
  14. package/dist/honesty/blind-spots.d.ts +9 -0
  15. package/dist/honesty/blind-spots.js +17 -4
  16. package/dist/honesty/gap-ledger.d.ts +52 -0
  17. package/dist/honesty/gap-ledger.js +63 -0
  18. package/dist/honesty/instrumentation-gaps.d.ts +55 -0
  19. package/dist/honesty/instrumentation-gaps.js +21 -0
  20. package/dist/honesty/observability.d.ts +37 -0
  21. package/dist/honesty/observability.js +40 -0
  22. package/dist/index.d.ts +0 -1
  23. package/dist/index.js +41 -0
  24. package/dist/init/node-io.js +3 -0
  25. package/dist/init/run.d.ts +18 -0
  26. package/dist/init/run.js +34 -3
  27. package/dist/init/slash-command.d.ts +1 -1
  28. package/dist/init/slash-command.js +33 -10
  29. package/dist/journal/envelope.d.ts +24 -24
  30. package/dist/license/license.d.ts +8 -2
  31. package/dist/license/license.js +10 -15
  32. package/dist/project/artifact-root.d.ts +83 -0
  33. package/dist/project/artifact-root.js +112 -0
  34. package/dist/project/memory-fs.d.ts +9 -0
  35. package/dist/project/memory-fs.js +88 -0
  36. package/dist/project/project-store.d.ts +17 -0
  37. package/dist/project/project-store.js +33 -0
  38. package/dist/project/remember-project.d.ts +24 -0
  39. package/dist/project/remember-project.js +40 -0
  40. package/dist/project/session-root.d.ts +15 -0
  41. package/dist/project/session-root.js +31 -0
  42. package/dist/runs/run-tools.js +2 -1
  43. package/dist/session/lease-availability.d.ts +40 -0
  44. package/dist/session/lease-availability.js +43 -0
  45. package/dist/session/no-session-diagnosis.d.ts +10 -0
  46. package/dist/session/no-session-diagnosis.js +19 -7
  47. package/dist/session/no-session-watch.js +1 -1
  48. package/dist/session/session.d.ts +9 -0
  49. package/dist/session/session.js +9 -0
  50. package/dist/session/stall-clock.d.ts +18 -0
  51. package/dist/session/stall-clock.js +48 -0
  52. package/dist/telemetry/daemon-telemetry.js +12 -23
  53. package/dist/telemetry/session-metrics.d.ts +1 -1
  54. package/dist/telemetry/session-metrics.js +1 -1
  55. package/dist/telemetry/telemetry.d.ts +12 -3
  56. package/dist/telemetry/telemetry.js +25 -5
  57. package/dist/tools/act-tools.js +34 -4
  58. package/dist/tools/assert-verdict.d.ts +2 -0
  59. package/dist/tools/assert-verdict.js +24 -1
  60. package/dist/tools/contract-tools.js +5 -3
  61. package/dist/tools/coverage-tools.js +33 -0
  62. package/dist/tools/observe-tools.js +8 -2
  63. package/dist/tools/tool-kit.d.ts +16 -1
  64. package/docs/README.md +1 -0
  65. package/docs/docs.json +2 -2
  66. package/docs/enterprise.md +3 -1
  67. package/docs/license-activation.mdx +145 -0
  68. package/docs/telemetry-contract.md +1 -12
  69. package/docs/telemetry-events.mdx +1 -34
  70. package/docs/telemetry.md +0 -1
  71. package/package.json +2 -2
  72. package/dist/telemetry/instrumentation-stall.d.ts +0 -57
  73. package/dist/telemetry/instrumentation-stall.js +0 -103
package/SKILL.md CHANGED
@@ -14,6 +14,18 @@ This file is the whole critical path and nothing else. Everything it leaves out
14
14
 
15
15
  **Do not tell the user Reticle is set up until step 5 has produced a verdict.** The most common failure in the field is an agent that finishes step 1, reports success, and leaves a user with config files and no instrumented page.
16
16
 
17
+ ## Setup runs to the end without you
18
+
19
+ **Do not stop, and do not ask the user anything, until their app is running with Reticle inside it and a session is connected.** Everything between here and that point is yours to do: run `init`, fix the wiring it could not, start the dev server, open the browser. Not one of those needs a human, and every one of them has been the place an onboarding turn died with nothing to show.
20
+
21
+ Questions you must not ask, because the repo you are sitting in already answers them: which framework, which package manager, which port, which editor or MCP client, whether to start the dev server, whether to open the browser, whether to carry on. Decide, act, and say what you did in one line.
22
+
23
+ There are exactly three places you may stop, and none is a question about a preference:
24
+
25
+ 1. **No recognisable dev script in `package.json`.** Say so; do not invent one.
26
+ 2. **Your host asks the human to approve a command.** That prompt belongs to the host. Never bypass or suppress it, and take a refusal as the answer.
27
+ 3. **On a first install only, the client restart that loads the MCP tools** (step 2). Even then, keep going until the app is running and connected, and stop for the restart last, with only the drive left.
28
+
17
29
  Setup requires a client restart, which ends your turn. This file was fetched into the context that just ended, so nothing carries it across unless you make it. **Before you trigger the restart, write the remaining steps into `RETICLE-SETUP.md` at the repo root**, one line each, with the step you stopped at marked. That file is what you re-read afterwards. Delete it once step 5 has produced a verdict.
18
30
 
19
31
  Better still, install the on-disk copy first. It survives the restart on its own, and it is the same content:
@@ -80,25 +92,29 @@ RETICLE_INSTALL_SOURCE=skill_file npx @reticlehq/server@latest init
80
92
 
81
93
  It detects the framework, package manager and UI library, registers the MCP server with the agents on the machine, installs the SDK, writes `.reticle.json` and the `/reticle` command, wires the build config, and scaffolds a capabilities file from the `data-testid` values it found.
82
94
 
83
- **Ask nothing:** not the framework, package manager, port, editor, or MCP client. Every one is answerable from the repo you are sitting in. In a monorepo run it at the root anyway; it finds the app under `apps/*` or `packages/*`.
95
+ **Ask nothing:** not the framework, package manager, port, editor, or MCP client. Every one is answerable from the repo you are sitting in. In a monorepo run it at the root anyway. With one app under `apps/*` or `packages/*` it wires that app silently. With several it wires nothing, lists them, and names the flag: re-run with `--app <dir>` for the one the user is working in, the one their request named, or the one whose `package.json` has the dev script. **Pick it yourself and re-run.** Asking which app they meant is the most likely place this whole install stops, and the answer is almost always in the request you were already given.
84
96
 
85
97
  **Never ask about the port.** There are two, and conflating them is a top setup failure. The dev-server port (3000, 5173) belongs to the project's own dev script and the daemon never binds it. The bridge port (**4400**) is the daemon-to-SDK channel and defaults correctly.
86
98
 
87
99
  Read the report: `✓` applied, `·` already wired, `–` skipped, `ℹ` done but incomplete in a way that matters, `⚠` needs your edit. **`⚠` and `ℹ` both need you.** `ℹ` is the one people skim past: the step ran, and something about the result still stops a session appearing. Each line carries the exact snippet. A non-zero exit is a to-do list, not a failed install. Fix every `⚠` before moving on. Per-framework wiring is at `https://docs.reticle.sh/install-manual.md`.
88
100
 
89
- ## 2. Register the MCP server, then restart the client.
101
+ **If the user gave you a license key**, along with the install request or at any later point, see [License key](#license-key) below and do it before moving on.
90
102
 
91
- Call `reticle_sessions`. If the tool exists, skip to step 3.
103
+ ## 2. Check for the tools. Do not stop for them.
92
104
 
93
- If it does not: your client read its server list at startup and has not re-read it. No retry loads it. `init` registers globally once per machine, so this bites on the first install only.
105
+ Call `reticle_sessions`. If the tool exists, go to step 3 and never mention a restart.
94
106
 
95
- Say this once and then stop:
107
+ If it does not: your client read its server list at startup and has not re-read it. No retry, config edit or slash command loads it into the process you are already in. `init` registers globally once per machine, so this bites on the first install only.
96
108
 
97
- > "Reticle is installed. Restart your client so it picks up the new MCP server, then say **'continue Reticle setup'**. Three steps are left, and your app is not instrumented until they are done."
109
+ **This is not a reason to stop.** Steps 3 and 4 need no MCP tools at all: they are a build config, a dev server and a browser tab, and every one of them is yours to do from the shell. An install that halts here leaves the user with config files, an uninstrumented page, and a question to answer; that is the single most common failure in the field, and it is this paragraph's fault when it happens.
110
+
111
+ So carry straight on through steps 3 and 4. Ask for the restart **once, at the end**, when a session is connected and the only thing left is the drive:
112
+
113
+ > "Reticle is installed and your app is connected. Restart your client so it picks up the MCP server, then say **'continue Reticle setup'**. One step is left: driving a flow."
98
114
 
99
115
  Claude Code: restart (`/mcp` does not re-read the config). VS Code: press Start in `.vscode/mcp.json`. Cursor, Windsurf, Zed: reload the window.
100
116
 
101
- **Do not report setup as finished here.** When the tools return, resume at step 3.
117
+ **Do not report setup as finished at that point either.** When the tools return, resume at step 5.
102
118
 
103
119
  ## 3. Wire the SDK into the app, and start the dev server.
104
120
 
@@ -132,7 +148,17 @@ Five guards, none optional:
132
148
  4. **Background it, and say so.** A dev server the human does not know about is the same failure one step later.
133
149
  5. **The permission prompt belongs to your host.** Never bypass, suppress or auto-approve it, and take a refusal as the answer.
134
150
 
135
- Then ask the user to open the app in their browser.
151
+ Then open the app yourself. Do not ask the user to do it. A setup turn that ends on "now open your browser" ends with nothing verified:
152
+
153
+ ```bash
154
+ npx @reticlehq/server open <the url the dev server is serving>
155
+ ```
156
+
157
+ That reuses an already-connected tab or opens a new one, and waits for the page to register. On a headless machine with no browser to open, take a tab Reticle owns instead, once the tools are reachable:
158
+
159
+ ```
160
+ reticle_run({ tool: "reticle_lease", args: { action: "acquire", url: "<the same url>" } })
161
+ ```
136
162
 
137
163
  ## 4. Prove a page is connected. This is a gate.
138
164
 
@@ -159,7 +185,7 @@ Drive it in as few calls as you can. Every call is a full model turn, and in a c
159
185
  3. `reticle_act_and_wait({ ref, action, until })` for the final step only. This is the call that produces the verdict, and `until` names the expected consequence before the action fires.
160
186
  4. `reticle_state()` once at the end.
161
187
 
162
- Four calls for a login, not fourteen. If `reticle_state` comes back empty or `hasCapabilities` is false, the generated capabilities file registered no store. Say so in one line and offer to finish it. Never report a clean install over an empty state read.
188
+ Four calls for a login, not fourteen. If `reticle_state` comes back empty or `hasCapabilities` is false, the capabilities file `init` generated registered nothing, which is what its `ℹ AGENT: finish the capabilities file` line was telling you. **Finish it yourself, before you drive**: open `src/reticle-dev.*`, register the app's store if it has one, and list the `testids` the flow you picked actually touches. Do not offer, and do not ask; it is a few lines in a file `init` already wrote for you. Never report a clean install over an empty state read.
163
189
 
164
190
  **Then save what you just drove.** Two calls, and they are what make every future check a single call instead of a repeat of this one. Wrap the drive above in a recording:
165
191
 
@@ -190,6 +216,18 @@ Stop here. Do not continue into VERIFY.
190
216
 
191
217
  ---
192
218
 
219
+ # License key
220
+
221
+ Enterprise features are unlocked by a signed key, verified locally with Ed25519 and **no network call**. If the user hands you one, during setup or at any later point:
222
+
223
+ 1. Append `RETICLE_LICENSE_KEY=<key>` to the project's `.env`, creating the file if it is not there. The CLI folds a project-local `.env` into the environment on every invocation, so nothing else has to change.
224
+ 2. Make sure `.env` is in `.gitignore`. A license key in git is a leaked credential, so check before you write and add the line if it is missing.
225
+ 3. Confirm with `npx @reticlehq/server license`, which prints `active`, `eval`, `missing` or `expired`.
226
+
227
+ Never echo the key back in your reply, and never put it in a commit, a code comment, or a feedback report. The rest: `curl https://docs.reticle.sh/enterprise.md`.
228
+
229
+ ---
230
+
193
231
  # VERIFY
194
232
 
195
233
  **Only `reticle_act_and_wait` and `reticle_assert` produce a verdict.** Everything else (`act`, `snapshot`, `query`, `navigate`, `observe`, `network`, `console`) moves or reads the app and proves nothing. A drive that ends without one of those two has no result, however many tools it used.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * An issue we have already fixed must not still read as available work.
3
+ *
4
+ * Three contributor PRs died on arrival in one week for this. #373 was filed at 09:52 and fixed by
5
+ * us at 12:33 the same day, then left open for three more days — somebody started on it on day two.
6
+ * #398 shipped and stayed open; a second contributor rebuilt it from scratch. `GITHUB.md` already
7
+ * documents the same failure costing three contributors before either of those, which makes this the
8
+ * third recurrence of a defect we have written down and asked ourselves to remember.
9
+ *
10
+ * Remembering is not a mechanism, and this is the smallest one that works: a commit saying it closes
11
+ * an issue is a claim that the issue is done. If that issue is still open and unlabelled afterwards,
12
+ * the tracker is lying to whoever reads it next — and the person it lies to is by definition someone
13
+ * looking for something to work on.
14
+ *
15
+ * ## Why `fixed-pending-release` is an accepted answer
16
+ *
17
+ * Work fixed on an unreleased branch is legitimately still open; the issue closes when the version
18
+ * ships. The label is what tells a contributor not to start, so the label — not the closure — is
19
+ * what this guard is actually asking for. That is also why the fix is cheap: nobody has to change
20
+ * when they close things, only to say so.
21
+ *
22
+ * Pure. The git log and the GitHub lookup belong to the caller, which is what makes every branch
23
+ * below testable without a network or a repo.
24
+ */
25
+ export interface IssueState {
26
+ number: number;
27
+ state: 'open' | 'closed';
28
+ labels: readonly string[];
29
+ }
30
+ /** The label that means "fixed, waiting for a release" — an honest reason to still be open. */
31
+ export declare const PENDING_RELEASE_LABEL = "fixed-pending-release";
32
+ export declare function closedRefsIn(commitMessage: string): number[];
33
+ /**
34
+ * Of the issues these commits claimed to close, the ones still reading as available work.
35
+ *
36
+ * An issue that could not be looked up is deliberately silent rather than failing. A rate limit, a
37
+ * transferred issue or a deleted one would otherwise turn this red for a reason unrelated to what it
38
+ * guards, and a guard that goes red for unrelated reasons gets disabled — which costs more than the
39
+ * defect it was catching.
40
+ */
41
+ export declare function staleIssues(claimed: readonly number[], issues: readonly IssueState[]): number[];
42
+ /** The message the guard prints. Held here so the check and its tests cannot describe it differently. */
43
+ export declare function staleIssueReport(stale: readonly number[]): string;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * An issue we have already fixed must not still read as available work.
3
+ *
4
+ * Three contributor PRs died on arrival in one week for this. #373 was filed at 09:52 and fixed by
5
+ * us at 12:33 the same day, then left open for three more days — somebody started on it on day two.
6
+ * #398 shipped and stayed open; a second contributor rebuilt it from scratch. `GITHUB.md` already
7
+ * documents the same failure costing three contributors before either of those, which makes this the
8
+ * third recurrence of a defect we have written down and asked ourselves to remember.
9
+ *
10
+ * Remembering is not a mechanism, and this is the smallest one that works: a commit saying it closes
11
+ * an issue is a claim that the issue is done. If that issue is still open and unlabelled afterwards,
12
+ * the tracker is lying to whoever reads it next — and the person it lies to is by definition someone
13
+ * looking for something to work on.
14
+ *
15
+ * ## Why `fixed-pending-release` is an accepted answer
16
+ *
17
+ * Work fixed on an unreleased branch is legitimately still open; the issue closes when the version
18
+ * ships. The label is what tells a contributor not to start, so the label — not the closure — is
19
+ * what this guard is actually asking for. That is also why the fix is cheap: nobody has to change
20
+ * when they close things, only to say so.
21
+ *
22
+ * Pure. The git log and the GitHub lookup belong to the caller, which is what makes every branch
23
+ * below testable without a network or a repo.
24
+ */
25
+ /** The label that means "fixed, waiting for a release" — an honest reason to still be open. */
26
+ export const PENDING_RELEASE_LABEL = 'fixed-pending-release';
27
+ /**
28
+ * Issue numbers a commit message CLAIMS to have closed.
29
+ *
30
+ * Only the closing verbs GitHub itself acts on. A bare `#447`, a `Refs #447` or a `see #447` is how
31
+ * a change points at context it did not close — the exact habit we want to encourage — so counting
32
+ * those would make the guard cry wolf on good behaviour, and a check that cries wolf is one people
33
+ * learn to skip.
34
+ */
35
+ const CLOSING_REF = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/gi;
36
+ export function closedRefsIn(commitMessage) {
37
+ const seen = new Set();
38
+ for (const match of commitMessage.matchAll(CLOSING_REF)) {
39
+ const raw = match[1];
40
+ if (raw === undefined)
41
+ continue;
42
+ const parsed = Number.parseInt(raw, 10);
43
+ if (Number.isFinite(parsed))
44
+ seen.add(parsed);
45
+ }
46
+ return [...seen];
47
+ }
48
+ /**
49
+ * Of the issues these commits claimed to close, the ones still reading as available work.
50
+ *
51
+ * An issue that could not be looked up is deliberately silent rather than failing. A rate limit, a
52
+ * transferred issue or a deleted one would otherwise turn this red for a reason unrelated to what it
53
+ * guards, and a guard that goes red for unrelated reasons gets disabled — which costs more than the
54
+ * defect it was catching.
55
+ */
56
+ export function staleIssues(claimed, issues) {
57
+ const byNumber = new Map(issues.map((issue) => [issue.number, issue]));
58
+ return claimed.filter((number) => {
59
+ const issue = byNumber.get(number);
60
+ if (issue === undefined)
61
+ return false;
62
+ if ('closed' === issue.state)
63
+ return false;
64
+ return !issue.labels.includes(PENDING_RELEASE_LABEL);
65
+ });
66
+ }
67
+ /** The message the guard prints. Held here so the check and its tests cannot describe it differently. */
68
+ export function staleIssueReport(stale) {
69
+ const list = stale.map((n) => `#${String(n)}`).join(', ');
70
+ return (`${list} ${1 === stale.length ? 'is' : 'are'} still OPEN, and a commit on this branch says it ` +
71
+ `closes ${1 === stale.length ? 'it' : 'them'}. Anyone browsing the tracker reads that as work ` +
72
+ `nobody has started. Close ${1 === stale.length ? 'it' : 'them'}, or label ` +
73
+ `${1 === stale.length ? 'it' : 'them'} \`${PENDING_RELEASE_LABEL}\` if the fix is waiting on a ` +
74
+ `release. This has cost three contributors an evening each; it is not bookkeeping.`);
75
+ }
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
- import { asNumber } from '../tools/tools-helpers.js';
2
+ import { sessionRoot } from '../project/session-root.js';
3
+ import { asNumber, asString } from '../tools/tools-helpers.js';
3
4
  import { ReticleTool } from '../tools/tool-names.js';
4
5
  import { readContract } from '../project/reticle-dir.js';
5
6
  import { buildDomainModel } from './domain-model.js';
@@ -74,7 +75,7 @@ export const DOMAIN_TOOLS = [
74
75
  if (loaded.ok)
75
76
  flows.push(loaded.value);
76
77
  }
77
- const contract = await readContract(deps.fs, deps.reticleRoot);
78
+ const contract = await readContract(deps.fs, sessionRoot(deps, asString(args['sessionId'])));
78
79
  const project = await deps.project.read();
79
80
  const runs = project.ok ? project.file.runs : [];
80
81
  const model = buildDomainModel(flows, contract.ok ? contract.capabilities : null, runs);
@@ -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[]>>;