@reticlehq/server 2.11.0 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +26 -2
- package/dist/bridge/bridge.d.ts +0 -13
- package/dist/bridge/bridge.js +1 -1
- package/dist/bridge/bridge.test-harness.d.ts +2 -0
- package/dist/bridge/bridge.test-harness.js +8 -1
- package/dist/capsule/capsule-store.d.ts +2 -1
- package/dist/capsule/capsule-store.js +1 -1
- package/dist/capsule/causal-summary.d.ts +2 -2
- package/dist/chromium-launch-options.d.ts +28 -0
- package/dist/chromium-launch-options.js +29 -0
- package/dist/cli/cli-doctor.js +8 -0
- package/dist/cli/cli-flow-commands.d.ts +3 -1
- package/dist/cli/cli-flow-commands.js +34 -4
- package/dist/cli/cli-kill.d.ts +2 -1
- package/dist/cli/cli-parse.d.ts +3 -1
- package/dist/cli/cli-parse.js +29 -7
- package/dist/cli/cli-update-commands.js +20 -1
- package/dist/cli/cli-verify.d.ts +1 -1
- package/dist/cli/cli-verify.js +2 -2
- package/dist/cli/cloud-cli.js +12 -6
- package/dist/cli/config-discovery.d.ts +2 -1
- package/dist/cli/doctor-rows.d.ts +3 -1
- package/dist/cli/doctor-rows.js +3 -1
- package/dist/cli/gate-exit.d.ts +21 -0
- package/dist/cli/gate-exit.js +21 -0
- package/dist/cli/gate-hook-message.d.ts +33 -0
- package/dist/cli/gate-hook-message.js +46 -0
- package/dist/cli/mcp-command.d.ts +25 -0
- package/dist/cli/mcp-command.js +143 -0
- package/dist/cli/sibling-ports.d.ts +37 -0
- package/dist/cli/sibling-ports.js +56 -0
- package/dist/cli.js +37 -110
- package/dist/cloud/cloud-config.d.ts +2 -1
- package/dist/cloud/cloud-sync.d.ts +11 -0
- package/dist/cloud/cloud-sync.js +32 -0
- package/dist/crawl/crawl.d.ts +9 -13
- package/dist/crawl/crawl.js +20 -3
- package/dist/daemon/daemon-resolve.d.ts +79 -0
- package/dist/daemon/daemon-resolve.js +118 -0
- package/dist/daemon/free-port.d.ts +29 -0
- package/dist/daemon/free-port.js +55 -0
- package/dist/daemon/heartbeat.d.ts +0 -4
- package/dist/daemon/heartbeat.js +2 -2
- package/dist/dev/stale-issue-guard.d.ts +0 -2
- package/dist/dev/stale-issue-guard.js +1 -1
- package/dist/events/contradictions.d.ts +41 -0
- package/dist/events/contradictions.js +125 -12
- package/dist/events/declared.d.ts +14 -0
- package/dist/events/declared.js +33 -0
- package/dist/events/echo-mismatch.js +14 -0
- package/dist/events/observed-in-window.d.ts +12 -0
- package/dist/events/observed-in-window.js +17 -0
- package/dist/events/predicate-asks.d.ts +13 -0
- package/dist/events/predicate-asks.js +22 -0
- package/dist/events/predicate-eval.js +64 -22
- package/dist/events/predicate-parse.d.ts +0 -6
- package/dist/events/predicate-parse.js +43 -3
- package/dist/events/predicate-schema.d.ts +44 -0
- package/dist/events/predicate-schema.js +126 -23
- package/dist/events/predicate.d.ts +15 -0
- package/dist/events/predicate.js +41 -2
- package/dist/flows/coverage.d.ts +0 -8
- package/dist/flows/coverage.js +1 -1
- package/dist/flows/decision.d.ts +5 -1
- package/dist/flows/decision.js +33 -7
- package/dist/flows/flake.d.ts +0 -2
- package/dist/flows/flake.js +1 -1
- package/dist/flows/flow-intent.d.ts +52 -0
- package/dist/flows/flow-intent.js +123 -0
- package/dist/flows/flow-replay-run.js +27 -4
- package/dist/flows/flow-success.js +15 -3
- package/dist/flows/flow-tools.js +32 -4
- package/dist/flows/flows.d.ts +6 -1
- package/dist/flows/flows.js +34 -22
- package/dist/flows/predicate-to-expect.js +6 -3
- package/dist/flows/server-verify.js +16 -2
- package/dist/flows/verify-change-tools.js +4 -1
- package/dist/honesty/blind-spots.d.ts +0 -17
- package/dist/honesty/blind-spots.js +1 -1
- package/dist/honesty/feature-capture.d.ts +150 -0
- package/dist/honesty/feature-capture.js +233 -0
- package/dist/honesty/instrumentation-gaps.d.ts +30 -6
- package/dist/honesty/instrumentation-gaps.js +61 -5
- package/dist/honesty/tool-hit-rate.d.ts +86 -0
- package/dist/honesty/tool-hit-rate.js +90 -0
- package/dist/honesty/undeclared-change.d.ts +51 -0
- package/dist/honesty/undeclared-change.js +56 -0
- package/dist/honesty/verified.d.ts +15 -0
- package/dist/honesty/verified.js +68 -4
- package/dist/http-server.d.ts +0 -1
- package/dist/http-server.js +1 -1
- package/dist/hunt/hunt-report.d.ts +0 -2
- package/dist/hunt/hunt-report.js +1 -1
- package/dist/impact/impact-store.d.ts +0 -5
- package/dist/impact/impact-store.js +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/init/agent-rules.d.ts +17 -0
- package/dist/init/agent-rules.js +31 -1
- package/dist/init/capabilities.d.ts +0 -2
- package/dist/init/capabilities.js +1 -1
- package/dist/init/closing-hint.d.ts +36 -0
- package/dist/init/closing-hint.js +120 -0
- package/dist/init/confirm.d.ts +0 -25
- package/dist/init/confirm.js +3 -3
- package/dist/init/cra.d.ts +14 -1
- package/dist/init/cra.js +43 -12
- package/dist/init/dev-script.d.ts +48 -0
- package/dist/init/dev-script.js +63 -0
- package/dist/init/mcp.d.ts +16 -5
- package/dist/init/mcp.js +26 -10
- package/dist/init/plan-framework.d.ts +0 -5
- package/dist/init/plan-framework.js +83 -9
- package/dist/init/plan.d.ts +10 -30
- package/dist/init/plan.js +30 -15
- package/dist/init/refresh-rules.d.ts +39 -0
- package/dist/init/refresh-rules.js +51 -0
- package/dist/init/run.d.ts +0 -26
- package/dist/init/run.js +14 -87
- package/dist/init/snippets.js +53 -10
- package/dist/input/real-input.d.ts +2 -0
- package/dist/input/real-input.js +3 -2
- package/dist/input/scroll-find.d.ts +7 -4
- package/dist/input/scroll-find.js +63 -25
- package/dist/intent/inline-intent.d.ts +46 -0
- package/dist/intent/inline-intent.js +103 -0
- package/dist/intent/intent-store.d.ts +37 -0
- package/dist/intent/intent-store.js +112 -0
- package/dist/intent/intent-tools.d.ts +2 -0
- package/dist/intent/intent-tools.js +80 -0
- package/dist/intent/open-intents.d.ts +21 -0
- package/dist/intent/open-intents.js +25 -0
- package/dist/journal/envelope.d.ts +26 -26
- package/dist/journal/journal-recorder.d.ts +23 -0
- package/dist/journal/journal-recorder.js +35 -0
- package/dist/mcp/attach-memory.d.ts +67 -0
- package/dist/mcp/attach-memory.js +142 -0
- package/dist/mcp/mcp-proxy.d.ts +0 -7
- package/dist/mcp/mcp-proxy.js +14 -2
- package/dist/mcp/mcp.d.ts +2 -0
- package/dist/mcp/mcp.js +45 -23
- package/dist/mcp/server-instructions.js +45 -3
- package/dist/mcp/tool-catalog-cache.d.ts +8 -0
- package/dist/mcp/tool-catalog-cache.js +10 -0
- package/dist/oracles/flow-instrument-gaps.d.ts +4 -4
- package/dist/oracles/flow-instrument-gaps.js +4 -5
- package/dist/oracles/self-instrument.d.ts +9 -9
- package/dist/oracles/self-instrument.js +19 -13
- package/dist/pool/browser-pool.d.ts +12 -1
- package/dist/pool/browser-pool.js +31 -1
- package/dist/pool/playwright-launcher.js +3 -1
- package/dist/project/fs-port.d.ts +3 -0
- package/dist/project/fs-port.js +3 -2
- package/dist/project/memory-fs.js +6 -1
- package/dist/project/project-tools.js +3 -32
- package/dist/project/reticle-dir.d.ts +2 -0
- package/dist/project/reticle-dir.js +1 -0
- package/dist/runs/context-tools.d.ts +2 -0
- package/dist/runs/context-tools.js +89 -0
- package/dist/runs/run-context.d.ts +40 -0
- package/dist/runs/run-context.js +158 -0
- package/dist/runs/run-diff.d.ts +2 -1
- package/dist/runs/verification-sync.js +2 -2
- package/dist/session/dev-server-probe.d.ts +0 -8
- package/dist/session/dev-server-probe.js +1 -1
- package/dist/session/last-act.d.ts +7 -1
- package/dist/session/last-act.js +2 -2
- package/dist/session/live-control-tools.js +1 -42
- package/dist/session/no-session-diagnosis.d.ts +10 -0
- package/dist/session/no-session-diagnosis.js +24 -7
- package/dist/session/no-session-next-action.d.ts +5 -0
- package/dist/session/no-session-next-action.js +35 -3
- package/dist/session/no-session-watch.d.ts +8 -0
- package/dist/session/no-session-watch.js +82 -64
- package/dist/session/output-budget.js +4 -1
- package/dist/session/session-health.d.ts +9 -0
- package/dist/session/session-health.js +23 -0
- package/dist/session/session-manager.js +50 -1
- package/dist/session/session-successor.d.ts +37 -0
- package/dist/session/session-successor.js +78 -0
- package/dist/session/session.d.ts +31 -1
- package/dist/session/session.js +75 -2
- package/dist/telemetry/automation-hint.d.ts +2 -1
- package/dist/telemetry/feedback-outbox.d.ts +2 -1
- package/dist/telemetry/project-profile.d.ts +0 -8
- package/dist/telemetry/project-profile.js +1 -1
- package/dist/telemetry/run-telemetry.d.ts +0 -2
- package/dist/telemetry/run-telemetry.js +1 -1
- package/dist/telemetry/verification-of.js +2 -2
- package/dist/tools/act-tools.js +126 -21
- package/dist/tools/assert-source.d.ts +22 -0
- package/dist/tools/assert-source.js +53 -0
- package/dist/tools/assert-verdict.d.ts +20 -3
- package/dist/tools/assert-verdict.js +36 -4
- package/dist/tools/browser-tools.js +0 -20
- package/dist/tools/complete-snapshot.d.ts +48 -0
- package/dist/tools/complete-snapshot.js +75 -0
- package/dist/tools/coverage-tools.d.ts +10 -1
- package/dist/tools/coverage-tools.js +123 -69
- package/dist/tools/dynamic-tools.js +29 -8
- package/dist/tools/error-recovery.d.ts +24 -14
- package/dist/tools/error-recovery.js +70 -9
- package/dist/tools/feedback-tools.d.ts +1 -1
- package/dist/tools/feedback-tools.js +2 -2
- package/dist/tools/invoke-tool.js +62 -2
- package/dist/tools/lease-hint.d.ts +10 -0
- package/dist/tools/lease-hint.js +53 -4
- package/dist/tools/lease-tools.d.ts +5 -1
- package/dist/tools/lease-tools.js +22 -8
- package/dist/tools/navigate-arrival.d.ts +0 -2
- package/dist/tools/navigate-arrival.js +1 -1
- package/dist/tools/observe-tools.js +63 -26
- package/dist/tools/read-tools.js +5 -3
- package/dist/tools/real-input-attempt.d.ts +22 -0
- package/dist/tools/real-input-attempt.js +153 -1
- package/dist/tools/reconcile-tools.js +18 -4
- package/dist/tools/resolve-target.d.ts +0 -7
- package/dist/tools/sdk-marker-probe.d.ts +29 -0
- package/dist/tools/sdk-marker-probe.js +57 -0
- package/dist/tools/snapshot-delta.d.ts +1 -1
- package/dist/tools/tool-kit.d.ts +12 -0
- package/dist/tools/tool-kit.js +16 -0
- package/dist/tools/tool-names.d.ts +3 -0
- package/dist/tools/tool-names.js +3 -0
- package/dist/tools/tool-surface.d.ts +85 -6
- package/dist/tools/tool-surface.js +122 -2
- package/dist/tools/tools.js +22 -4
- package/dist/tools/unadvertised-help.js +4 -4
- package/dist/tools/verify-next-baton.d.ts +53 -0
- package/dist/tools/verify-next-baton.js +52 -0
- package/dist/update/update-nudge.d.ts +7 -10
- package/dist/update/update-nudge.js +12 -3
- package/dist/update/updater.d.ts +1 -1
- package/docs/README.md +2 -1
- package/docs/cli/cloud.mdx +1 -1
- package/docs/cli/doctor.mdx +1 -0
- package/docs/cli/verify.mdx +4 -5
- package/docs/desktop.mdx +1 -1
- package/docs/docs.json +2 -2
- package/docs/gates.md +1 -1
- package/docs/install-manual.mdx +1 -1
- package/docs/packages/browser.mdx +2 -2
- package/docs/packages/server.mdx +1 -1
- package/docs/packages.mdx +8 -6
- package/docs/platform-integration.md +17 -0
- package/docs/predicates.mdx +8 -0
- package/docs/reticle-manual.md +106 -0
- package/docs/tools-tools-and-run.mdx +2 -4
- package/docs/troubleshooting.mdx +9 -0
- package/docs/usage.md +3 -2
- package/package.json +2 -2
- package/dist/session/session-readiness.d.ts +0 -34
- package/dist/session/session-readiness.js +0 -36
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { ContradictionKind, EventType, MUTATING_METHODS, isDevToolingUrl, } from '@reticlehq/core';
|
|
1
|
+
import { ContradictionKind, EventType, MUTATING_METHODS, isDevToolingUrl, isSameDocument, isSameEditEpoch, } from '@reticlehq/core';
|
|
2
|
+
import { describeSuperseded } from './observed-in-window.js';
|
|
2
3
|
import { findStaleResponses } from './stale-response.js';
|
|
3
4
|
import { findBodyFailures } from './body-failures.js';
|
|
4
5
|
import { findEchoMismatches } from './echo-mismatch.js';
|
|
@@ -162,9 +163,64 @@ function splitDevTooling(events) {
|
|
|
162
163
|
});
|
|
163
164
|
return { app, ignored };
|
|
164
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Cross-channel contradictions in this window, with the edit-epoch caveat attached when it applies.
|
|
168
|
+
*
|
|
169
|
+
* Cross-epoch evidence is LABELLED rather than excluded, which is the opposite of what the document
|
|
170
|
+
* scoping does, and the difference is the point. A navigation is total — it throws away the page,
|
|
171
|
+
* the refs, the in-flight requests and the state — so nothing recorded before it is still about the
|
|
172
|
+
* world, and dropping it is the only honest option. A hot update is not: most modules, most of the
|
|
173
|
+
* DOM, the whole network log and every console line survive one, so most of what was observed a
|
|
174
|
+
* second before an edit is still true a second after. Excluding it would empty windows that hold
|
|
175
|
+
* real findings, and an emptied window reads as "nothing happened" — which is the more expensive of
|
|
176
|
+
* the two wrong answers and the one this whole family of checks exists to prevent.
|
|
177
|
+
*
|
|
178
|
+
* So the findings stand and the caveat is said out loud, and only when it is unambiguous: EVERY
|
|
179
|
+
* observation in the window predates the edit. One post-edit observation and the agent is already
|
|
180
|
+
* looking at the code it wrote, so the label would be noise.
|
|
181
|
+
*/
|
|
165
182
|
export function findContradictions(allEvents, options = {}) {
|
|
183
|
+
const found = findWindowContradictions(allEvents, options);
|
|
184
|
+
const predates = allEvents.length > 0 &&
|
|
185
|
+
allEvents.every((e) => !isSameEditEpoch(e.editEpoch, options.currentEditEpoch));
|
|
186
|
+
if (!predates)
|
|
187
|
+
return found;
|
|
188
|
+
// Prepended, not appended: the caveat governs how everything under it should be read, and it has
|
|
189
|
+
// to survive the rules that return early with a single finding of their own.
|
|
190
|
+
return [
|
|
191
|
+
{
|
|
192
|
+
kind: ContradictionKind.EVIDENCE_PREDATES_EDIT,
|
|
193
|
+
claim: 'these observations describe the code as it is now',
|
|
194
|
+
counter: 'every one of them was recorded before the last hot update landed in the page',
|
|
195
|
+
detail: 'the source changed and the page re-rendered after this evidence was captured, so it describes code that has since been replaced — nothing here is necessarily wrong, but nothing here has seen the edit either. Drive the app again to verify the current code',
|
|
196
|
+
},
|
|
197
|
+
...found,
|
|
198
|
+
];
|
|
199
|
+
}
|
|
200
|
+
function findWindowContradictions(allEvents, options) {
|
|
166
201
|
const found = [];
|
|
167
|
-
const { app:
|
|
202
|
+
const { app: allApp, ignored: ignoredDevTooling } = splitDevTooling(allEvents);
|
|
203
|
+
// ── Evidence belonging to a document that has since been replaced ───────────────────────────
|
|
204
|
+
// Scoped ONCE, here, for the same reason the dev-tooling split is: every rule below asks "what
|
|
205
|
+
// else was in this window", and answering that with a dead page's traffic is a defect in all of
|
|
206
|
+
// them rather than in whichever one reported it. Applied after the dev-tooling split so the count
|
|
207
|
+
// reported below is the app's own evidence and not the toolchain's noise.
|
|
208
|
+
const events = allApp.filter((e) => isSameDocument(e.documentId, options.currentDocumentId));
|
|
209
|
+
const superseded = allApp.length - events.length;
|
|
210
|
+
// An empty window was always allowed to mean "nothing happened"; that reading is only unsafe once
|
|
211
|
+
// supersession is what emptied it. Reported ALONE and before every rule below, because a window
|
|
212
|
+
// with nothing left in it is exactly the shape `action-had-no-effect` fires on — so without this
|
|
213
|
+
// the fix would have swapped a wrong citation for a wrong accusation.
|
|
214
|
+
if (superseded > 0 && 0 === events.length) {
|
|
215
|
+
return [
|
|
216
|
+
{
|
|
217
|
+
kind: ContradictionKind.EVIDENCE_SUPERSEDED,
|
|
218
|
+
claim: 'this window holds observations that could answer for the action',
|
|
219
|
+
counter: describeSuperseded('observations', superseded),
|
|
220
|
+
detail: 'a full navigation or a reload built a new document, and everything recorded here belongs to the old one — citing it would name requests, errors and state that no longer describe anything on screen',
|
|
221
|
+
},
|
|
222
|
+
];
|
|
223
|
+
}
|
|
168
224
|
const settled = events.filter((e) => e.type === EventType.NET_REQUEST).map(netCall);
|
|
169
225
|
// ── Overlapping reads that settled out of order ─────────────────────────────────────────────
|
|
170
226
|
// Independent of any action, so it runs on every window: the race is a property of the timeline.
|
|
@@ -290,18 +346,71 @@ export function findContradictions(allEvents, options = {}) {
|
|
|
290
346
|
if (!advanced) {
|
|
291
347
|
const ignoredWrites = settled.filter((c) => true === c.ok && isMutating(c));
|
|
292
348
|
if (ignoredWrites.length > 0) {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
349
|
+
// ...unless THIS document handed the consequence to another browsing context. An OAuth sign-in
|
|
350
|
+
// posts, succeeds, and continues in a popup the in-page SDK cannot follow (#508): the original
|
|
351
|
+
// tab legitimately never changes, and response-ignored would accuse it of ignoring a response
|
|
352
|
+
// it handed off. The opened-context event flips the reading from "the client did nothing" to
|
|
353
|
+
// "the client went where we cannot look". Scoped like every rule here to the attribution floor
|
|
354
|
+
// (`options.actionSince`; the local below is declared later, for the window rules).
|
|
355
|
+
const contextFloor = options.actionSince;
|
|
356
|
+
const openedContext = events.some((e) => e.type === EventType.CONTEXT_OPENED && contextFloor !== undefined && e.t >= contextFloor);
|
|
357
|
+
found.push(openedContext
|
|
358
|
+
? {
|
|
359
|
+
kind: ContradictionKind.CONSEQUENCE_ELSEWHERE,
|
|
360
|
+
claim: `${String(ignoredWrites.length)} write(s) succeeded on the server`,
|
|
361
|
+
counter: 'this document never changed because the page opened another browsing context during this window (e.g. an OAuth popup), where the in-page SDK cannot observe the result',
|
|
362
|
+
detail: ignoredWrites.map(describe).join('; '),
|
|
363
|
+
}
|
|
364
|
+
: {
|
|
365
|
+
kind: ContradictionKind.RESPONSE_IGNORED,
|
|
366
|
+
claim: `${String(ignoredWrites.length)} write(s) succeeded on the server`,
|
|
367
|
+
counter: 'nothing on the client changed — no DOM, store or route movement',
|
|
368
|
+
detail: ignoredWrites.map(describe).join('; '),
|
|
369
|
+
});
|
|
299
370
|
}
|
|
300
371
|
}
|
|
372
|
+
// ── The app announced a consequence and nothing else moved ─────────────────────────────────
|
|
373
|
+
// Scoped as tightly as the evidence allows, because this rule fires on the ABSENCE of everything
|
|
374
|
+
// else and that is the easiest way to build a false positive.
|
|
375
|
+
//
|
|
376
|
+
// Only when the window is otherwise EMPTY: no DOM, no store, no route (`!advanced`) and no request
|
|
377
|
+
// at all. A request means the app reached for something, and whether it settled, failed or was
|
|
378
|
+
// ignored belongs to three other rules — firing here too would report one fact twice.
|
|
379
|
+
//
|
|
380
|
+
// `successSignals` reuses the failure-shaped filter above: an app that announced `deploy:failed`
|
|
381
|
+
// is correctly reporting that nothing happened, and accusing it inverts the meaning of the one app
|
|
382
|
+
// doing this right.
|
|
383
|
+
//
|
|
384
|
+
// And only for a window attributed to an ACTION. `reticle_assert` OBSERVES — there is no click
|
|
385
|
+
// whose consequence should have corroborated anything, so an assert over a quiet window carrying
|
|
386
|
+
// one signal is an ordinary read, not a claim nothing backs. Without this the rule reddened eight
|
|
387
|
+
// existing tests that assert exactly that, which is the false-positive class this scoping exists
|
|
388
|
+
// to prevent.
|
|
389
|
+
if (options.actionSince !== undefined &&
|
|
390
|
+
!advanced &&
|
|
391
|
+
0 === settled.length &&
|
|
392
|
+
successSignals.length > 0) {
|
|
393
|
+
found.push({
|
|
394
|
+
kind: ContradictionKind.SIGNAL_WITHOUT_CONSEQUENCE,
|
|
395
|
+
claim: `the app fired ${successSignals.map((s) => `"${s}"`).join(', ')}`,
|
|
396
|
+
counter: 'nothing else in the window moved — no DOM, no store, no route, no request — so the only ' +
|
|
397
|
+
'evidence that anything happened is the app saying so',
|
|
398
|
+
detail: 'a signal emitted from the value the app was ASKED for, rather than the one it committed, ' +
|
|
399
|
+
'reads identically to one that worked',
|
|
400
|
+
});
|
|
401
|
+
}
|
|
301
402
|
// ── The same write fired more than once, inside ONE action's window ─────────────────────────
|
|
302
403
|
// Attribution is the whole rule here, not a refinement of it: counting `method + url` over whatever
|
|
303
404
|
// window the caller handed in turns two legitimate separate saves into a double submit. See
|
|
304
405
|
// ContradictionOptions.actionSince.
|
|
406
|
+
//
|
|
407
|
+
// A captured request body joins the identity. A command-bus API posts every mutation to one URL
|
|
408
|
+
// and discriminates on a body field (`{"command":"study.stage.set"}` vs `{"command":"mesh.plan"}`),
|
|
409
|
+
// so under URL alone each of those distinct writes read as a repeat of the first, and clean
|
|
410
|
+
// verdicts degraded to unknown behind doubles that were never doubles. When no body was captured
|
|
411
|
+
// (capture off, or a non-text body, which reports a type marker instead) there is nothing to
|
|
412
|
+
// compare and the URL alone stands, as before; a window where only some calls carried bodies
|
|
413
|
+
// compares nothing rather than guess.
|
|
305
414
|
const actionSince = options.actionSince;
|
|
306
415
|
if (actionSince !== undefined) {
|
|
307
416
|
const writeCounts = new Map();
|
|
@@ -311,17 +420,21 @@ export function findContradictions(allEvents, options = {}) {
|
|
|
311
420
|
const call = netCall(event);
|
|
312
421
|
if (!isMutating(call))
|
|
313
422
|
continue;
|
|
314
|
-
const
|
|
315
|
-
|
|
423
|
+
const label = `${call.method} ${call.url}`;
|
|
424
|
+
const body = asString(event.data['requestBody']);
|
|
425
|
+
const key = body === undefined || 0 === body.length ? label : `${label} ${body}`;
|
|
426
|
+
const entry = writeCounts.get(key) ?? { label, count: 0 };
|
|
427
|
+
entry.count += 1;
|
|
428
|
+
writeCounts.set(key, entry);
|
|
316
429
|
}
|
|
317
|
-
for (const [
|
|
430
|
+
for (const [, { label, count }] of writeCounts) {
|
|
318
431
|
if (count < 2)
|
|
319
432
|
continue;
|
|
320
433
|
found.push({
|
|
321
434
|
kind: ContradictionKind.DUPLICATE_REQUEST,
|
|
322
435
|
claim: 'one user action was performed',
|
|
323
436
|
counter: `the same write fired ${String(count)} times`,
|
|
324
|
-
detail: `${
|
|
437
|
+
detail: `${label} ×${String(count)}`,
|
|
325
438
|
});
|
|
326
439
|
}
|
|
327
440
|
}
|
|
@@ -31,6 +31,20 @@ export interface DeclaredExpectations {
|
|
|
31
31
|
rendersContent: boolean;
|
|
32
32
|
}
|
|
33
33
|
export declare function declaredExpectations(predicate: Predicate | undefined): DeclaredExpectations;
|
|
34
|
+
/**
|
|
35
|
+
* Did the caller name a consequence that does not depend on the response body?
|
|
36
|
+
*
|
|
37
|
+
* The unread-body clause exists for the case where the body is the ONLY channel that could have
|
|
38
|
+
* contradicted the screen — a 200 with per-item failures inside, a GraphQL error that is also a
|
|
39
|
+
* 200. A declared string on screen, store path, or signal that held is a different channel, and
|
|
40
|
+
* grading `unknown` there costs a real verdict (measured: a 201 plus the unique row, unread body,
|
|
41
|
+
* agent went to enable capture instead of finishing the drive).
|
|
42
|
+
*
|
|
43
|
+
* Same conservatism as `declaredExpectations`: only the top level and `allOf`. An `anyOf` branch
|
|
44
|
+
* may never have held, and honouring it would skip the unread caveat on the strength of a net
|
|
45
|
+
* success that was the branch that actually matched.
|
|
46
|
+
*/
|
|
47
|
+
export declare function declaresBodyIndependentChannel(predicate: Predicate | undefined): boolean;
|
|
34
48
|
/**
|
|
35
49
|
* Does a call the window recorded match a failure the caller declared?
|
|
36
50
|
*
|
package/dist/events/declared.js
CHANGED
|
@@ -53,6 +53,39 @@ export function declaredExpectations(predicate) {
|
|
|
53
53
|
walk(predicate);
|
|
54
54
|
return { netFailures, rendersContent };
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Did the caller name a consequence that does not depend on the response body?
|
|
58
|
+
*
|
|
59
|
+
* The unread-body clause exists for the case where the body is the ONLY channel that could have
|
|
60
|
+
* contradicted the screen — a 200 with per-item failures inside, a GraphQL error that is also a
|
|
61
|
+
* 200. A declared string on screen, store path, or signal that held is a different channel, and
|
|
62
|
+
* grading `unknown` there costs a real verdict (measured: a 201 plus the unique row, unread body,
|
|
63
|
+
* agent went to enable capture instead of finishing the drive).
|
|
64
|
+
*
|
|
65
|
+
* Same conservatism as `declaredExpectations`: only the top level and `allOf`. An `anyOf` branch
|
|
66
|
+
* may never have held, and honouring it would skip the unread caveat on the strength of a net
|
|
67
|
+
* success that was the branch that actually matched.
|
|
68
|
+
*/
|
|
69
|
+
export function declaresBodyIndependentChannel(predicate) {
|
|
70
|
+
if (predicate === undefined)
|
|
71
|
+
return false;
|
|
72
|
+
const walk = (p) => {
|
|
73
|
+
switch (p.kind) {
|
|
74
|
+
case PredicateKind.ALL_OF:
|
|
75
|
+
return p.predicates.some(walk);
|
|
76
|
+
case PredicateKind.TEXT:
|
|
77
|
+
return true !== p.absent;
|
|
78
|
+
case PredicateKind.SIGNAL:
|
|
79
|
+
case PredicateKind.STATE:
|
|
80
|
+
return true;
|
|
81
|
+
case PredicateKind.ELEMENT:
|
|
82
|
+
return true !== p.absent && (p.query.value !== undefined || p.query.text !== undefined);
|
|
83
|
+
default:
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
return walk(predicate);
|
|
88
|
+
}
|
|
56
89
|
/**
|
|
57
90
|
* Does a call the window recorded match a failure the caller declared?
|
|
58
91
|
*
|
|
@@ -133,6 +133,20 @@ export function findEchoMismatches(events, actionSince) {
|
|
|
133
133
|
continue;
|
|
134
134
|
const echoed = scalarsByKey(response);
|
|
135
135
|
const asked = scalarsByKey(request);
|
|
136
|
+
// The response has to be ECHO-SHAPED before any key can contradict anything. An echo restates
|
|
137
|
+
// what was sent, so most of the request's scalar keys reappear somewhere in it; a snapshot that
|
|
138
|
+
// happens to share a key or two does not (#506: `{"command":"chat.send"}` answered with the
|
|
139
|
+
// viewer snapshot read one incidental shared key as an attempted echo of a write that fully
|
|
140
|
+
// applied). Requiring a strict majority of the request's keys to appear keeps every documented
|
|
141
|
+
// true positive — an envelope restates most of its fields — and deliberately trades the rare
|
|
142
|
+
// half-echoed write away, the same direction MAX_ECHO_KEYS already trades in.
|
|
143
|
+
let shared = 0;
|
|
144
|
+
for (const key of asked.keys()) {
|
|
145
|
+
if (echoed.has(key))
|
|
146
|
+
shared += 1;
|
|
147
|
+
}
|
|
148
|
+
if (shared * 2 <= asked.size)
|
|
149
|
+
continue;
|
|
136
150
|
const dropped = [];
|
|
137
151
|
for (const [key, wanted] of asked) {
|
|
138
152
|
// More than one requested value for a key (a before/after pair, a list of items) makes "what
|
|
@@ -10,3 +10,15 @@
|
|
|
10
10
|
* not do what you thought) versus things happened but not that one (you named it wrong).
|
|
11
11
|
*/
|
|
12
12
|
export declare function describeObserved(noun: string, seen: readonly string[]): string;
|
|
13
|
+
/**
|
|
14
|
+
* The third answer, and the one this file existed to make room for: things happened, and they belong
|
|
15
|
+
* to a document that has since been replaced.
|
|
16
|
+
*
|
|
17
|
+
* `describeObserved` already separates "nothing happened" from "things happened but not that one",
|
|
18
|
+
* because those need different fixes. A window whose evidence was discarded as superseded is a third
|
|
19
|
+
* case needing a third fix, and it is the one that reads most like the first: everything that could
|
|
20
|
+
* have answered the question is gone, so a bare "nothing happened in this window" is available and
|
|
21
|
+
* wrong. The remedy is in the sentence, because an agent that cannot act on a caveat learns to skip
|
|
22
|
+
* it — here the remedy is to drive the action again against the page now on screen.
|
|
23
|
+
*/
|
|
24
|
+
export declare function describeSuperseded(noun: string, count: number): string;
|
|
@@ -19,3 +19,20 @@ export function describeObserved(noun, seen) {
|
|
|
19
19
|
const rest = unique.length - shown.length;
|
|
20
20
|
return `${noun} seen in this window: ${shown.join(', ')}${rest > 0 ? `, and ${String(rest)} more` : ''}`;
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* The third answer, and the one this file existed to make room for: things happened, and they belong
|
|
24
|
+
* to a document that has since been replaced.
|
|
25
|
+
*
|
|
26
|
+
* `describeObserved` already separates "nothing happened" from "things happened but not that one",
|
|
27
|
+
* because those need different fixes. A window whose evidence was discarded as superseded is a third
|
|
28
|
+
* case needing a third fix, and it is the one that reads most like the first: everything that could
|
|
29
|
+
* have answered the question is gone, so a bare "nothing happened in this window" is available and
|
|
30
|
+
* wrong. The remedy is in the sentence, because an agent that cannot act on a caveat learns to skip
|
|
31
|
+
* it — here the remedy is to drive the action again against the page now on screen.
|
|
32
|
+
*/
|
|
33
|
+
export function describeSuperseded(noun, count) {
|
|
34
|
+
return (`no ${noun} belong to the document currently on screen: ${String(count)} ${noun} were observed ` +
|
|
35
|
+
`and every one of them belongs to a document that has since been replaced (the page reloaded or ` +
|
|
36
|
+
`navigated). This says nothing about the app — the evidence went away with the page that produced ` +
|
|
37
|
+
`it. Re-drive the action against the page now on screen`);
|
|
38
|
+
}
|
|
@@ -15,3 +15,16 @@ import type { Predicate } from './predicate-schema.js';
|
|
|
15
15
|
* that reads as the subject of an unrelated test is a half-hour somebody loses later.
|
|
16
16
|
*/
|
|
17
17
|
export declare function declaresState(predicate: Predicate): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Does the caller's predicate judge the DOM — an `element` or `text` clause anywhere in the tree?
|
|
20
|
+
*
|
|
21
|
+
* The question behind it is where a verdict is allowed to point. A DOM predicate has its own locus:
|
|
22
|
+
* the element it matched, whose `file:line` the descriptor already carries. A `signal`/`net`/`state`
|
|
23
|
+
* failure has none — there is no node for "the request was never made" — and that is the one case
|
|
24
|
+
* where the verdict borrows the last driven control's line, because the handler that should have
|
|
25
|
+
* fired lives there.
|
|
26
|
+
*
|
|
27
|
+
* Walks composites for the same reason `declaresState` does: `allOf[{element}, {signal}]` looked at
|
|
28
|
+
* the DOM exactly as much as a bare `{element}` did.
|
|
29
|
+
*/
|
|
30
|
+
export declare function declaresDom(predicate: Predicate): boolean;
|
|
@@ -24,3 +24,25 @@ export function declaresState(predicate) {
|
|
|
24
24
|
return declaresState(predicate.predicate);
|
|
25
25
|
return false;
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Does the caller's predicate judge the DOM — an `element` or `text` clause anywhere in the tree?
|
|
29
|
+
*
|
|
30
|
+
* The question behind it is where a verdict is allowed to point. A DOM predicate has its own locus:
|
|
31
|
+
* the element it matched, whose `file:line` the descriptor already carries. A `signal`/`net`/`state`
|
|
32
|
+
* failure has none — there is no node for "the request was never made" — and that is the one case
|
|
33
|
+
* where the verdict borrows the last driven control's line, because the handler that should have
|
|
34
|
+
* fired lives there.
|
|
35
|
+
*
|
|
36
|
+
* Walks composites for the same reason `declaresState` does: `allOf[{element}, {signal}]` looked at
|
|
37
|
+
* the DOM exactly as much as a bare `{element}` did.
|
|
38
|
+
*/
|
|
39
|
+
export function declaresDom(predicate) {
|
|
40
|
+
if (predicate.kind === PredicateKind.ELEMENT || predicate.kind === PredicateKind.TEXT)
|
|
41
|
+
return true;
|
|
42
|
+
if (predicate.kind === PredicateKind.ALL_OF || predicate.kind === PredicateKind.ANY_OF) {
|
|
43
|
+
return predicate.predicates.some(declaresDom);
|
|
44
|
+
}
|
|
45
|
+
if (predicate.kind === PredicateKind.NOT)
|
|
46
|
+
return declaresDom(predicate.predicate);
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
@@ -160,6 +160,35 @@ function describeNetFilter(p) {
|
|
|
160
160
|
const { kind: _kind, count: _count, since: _since, ...filter } = p;
|
|
161
161
|
return JSON.stringify(filter);
|
|
162
162
|
}
|
|
163
|
+
/** Same, for a signal: the matcher as applied, with the cardinality and the floor taken out. */
|
|
164
|
+
function describeSignalFilter(p) {
|
|
165
|
+
const { kind: _kind, count: _count, since: _since, ...filter } = p;
|
|
166
|
+
return JSON.stringify(filter);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Exact cardinality, once, for every channel that can count its matches.
|
|
170
|
+
*
|
|
171
|
+
* `net` and `signal` are the same assertion over different evidence — "this happened EXACTLY n
|
|
172
|
+
* times" — and the interesting half is the same on both: an over-count is `decided`, because a
|
|
173
|
+
* window only accumulates and a count cannot come back down, so waiting out the rest of the budget
|
|
174
|
+
* to report a double-fire buys nothing but latency.
|
|
175
|
+
*
|
|
176
|
+
* `noun` names the population in prose ("network call(s)", "signal(s)"), `filter` is the matcher as
|
|
177
|
+
* applied, and `assertion` is the oracle that judged it.
|
|
178
|
+
*/
|
|
179
|
+
function evalExactCount(args) {
|
|
180
|
+
const { matched, want, noun, filter, assertion } = args;
|
|
181
|
+
if (matched === want)
|
|
182
|
+
return { pass: true, evidence: { matched } };
|
|
183
|
+
return {
|
|
184
|
+
pass: false,
|
|
185
|
+
...(matched > want ? { decided: true } : {}),
|
|
186
|
+
failureReason: `expected ${String(want)} ${noun} matching ${filter}, saw ${String(matched)}`,
|
|
187
|
+
observed: `${String(matched)} matching ${noun}`,
|
|
188
|
+
expected: `exactly ${String(want)} matching ${filter}`,
|
|
189
|
+
assertion,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
163
192
|
/**
|
|
164
193
|
* URL suffixes only the DOCUMENT fetches, which the network observer therefore never records.
|
|
165
194
|
*
|
|
@@ -363,26 +392,24 @@ export function evalNet(events, p) {
|
|
|
363
392
|
// useEffect-double-fire / retry-storm regression class, where the request DID fire (presence passes)
|
|
364
393
|
// but fired the WRONG number of times. Without `count`, the matcher is presence-only (≥1).
|
|
365
394
|
if (p.count !== undefined) {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
assertion: 'net.count',
|
|
385
|
-
};
|
|
395
|
+
if (matches.length !== p.count &&
|
|
396
|
+
0 === matches.length &&
|
|
397
|
+
targetsUnobservedChannel(p) &&
|
|
398
|
+
!sawSubresources) {
|
|
399
|
+
return {
|
|
400
|
+
pass: false,
|
|
401
|
+
failureReason: unobservedChannelReason(p),
|
|
402
|
+
inconclusive: unobservedChannelReason(p),
|
|
403
|
+
assertion: 'net.count',
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
return evalExactCount({
|
|
407
|
+
matched: matches.length,
|
|
408
|
+
want: p.count,
|
|
409
|
+
noun: 'network call(s)',
|
|
410
|
+
filter: describeNetFilter(p),
|
|
411
|
+
assertion: 'net.count',
|
|
412
|
+
});
|
|
386
413
|
}
|
|
387
414
|
const hit = matches[0];
|
|
388
415
|
if (hit === undefined && targetsUnobservedChannel(p) && !sawSubresources) {
|
|
@@ -527,7 +554,7 @@ export function evalAnimation(events, p) {
|
|
|
527
554
|
};
|
|
528
555
|
}
|
|
529
556
|
export function evalSignal(events, p) {
|
|
530
|
-
const
|
|
557
|
+
const isMatch = (e) => {
|
|
531
558
|
if (e.type !== EventType.SIGNAL)
|
|
532
559
|
return false;
|
|
533
560
|
if (p.name !== undefined && str(e.data['name']) !== p.name)
|
|
@@ -538,7 +565,22 @@ export function evalSignal(events, p) {
|
|
|
538
565
|
return false;
|
|
539
566
|
}
|
|
540
567
|
return true;
|
|
541
|
-
}
|
|
568
|
+
};
|
|
569
|
+
// `count` (exact) turns presence into a cardinality assertion, exactly as it does on `net`. The
|
|
570
|
+
// double-fire is invisible to every state-only oracle — a handler wired twice leaves the store in
|
|
571
|
+
// the right shape and fires the signal twice — and so is the wrong-name fire, where the intended
|
|
572
|
+
// signal fires once beside a mistyped sibling and a presence check cannot say which is which.
|
|
573
|
+
// Counting only what the MATCHER matched is what separates them. Omit = presence (≥1).
|
|
574
|
+
if (p.count !== undefined) {
|
|
575
|
+
return evalExactCount({
|
|
576
|
+
matched: events.filter(isMatch).length,
|
|
577
|
+
want: p.count,
|
|
578
|
+
noun: 'signal(s)',
|
|
579
|
+
filter: describeSignalFilter(p),
|
|
580
|
+
assertion: 'signal.count',
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
const hit = events.find(isMatch);
|
|
542
584
|
if (hit !== undefined)
|
|
543
585
|
return { pass: true, evidence: hit.data };
|
|
544
586
|
// Near-miss: show signals that fired with the same name (so the agent sees the real data).
|
|
@@ -14,10 +14,4 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { z } from 'zod';
|
|
16
16
|
import { PredicateSchema } from './predicate-eval.js';
|
|
17
|
-
/**
|
|
18
|
-
* Parse a predicate, or throw an Error whose message is a sentence.
|
|
19
|
-
*
|
|
20
|
-
* Deliberately still THROWS: every call site already handles a throw, and turning this into a
|
|
21
|
-
* result type would mean touching three handlers to gain nothing the message does not already say.
|
|
22
|
-
*/
|
|
23
17
|
export declare function parsePredicate(input: unknown): z.infer<typeof PredicateSchema>;
|
|
@@ -67,7 +67,35 @@ function describeIssue(issue) {
|
|
|
67
67
|
* Deliberately still THROWS: every call site already handles a throw, and turning this into a
|
|
68
68
|
* result type would mean touching three handlers to gain nothing the message does not already say.
|
|
69
69
|
*/
|
|
70
|
+
/**
|
|
71
|
+
* The placeholder `verify_next` puts in the `until` it suggests, sent back unchanged.
|
|
72
|
+
*
|
|
73
|
+
* The nudge hands the agent a ready-made `act_and_wait` call with `ref` and `action` filled in from
|
|
74
|
+
* the act that dispatched, and leaves the consequence blank on purpose: naming it is the one part
|
|
75
|
+
* only the agent can know, and guessing would be Reticle inventing the assertion.
|
|
76
|
+
*
|
|
77
|
+
* Sent verbatim it parses as a perfectly valid text predicate that simply never matches, so the
|
|
78
|
+
* verdict came back `verified:"no"` / "the declared consequence did not hold" — blaming the app for
|
|
79
|
+
* a placeholder the agent forgot to replace, and sending it to hunt a defect that does not exist.
|
|
80
|
+
* Measured on a live app before this guard: a confident, wrong, actionable-looking answer, which is
|
|
81
|
+
* the exact failure the rest of this file exists to avoid.
|
|
82
|
+
*/
|
|
83
|
+
const UNFILLED_PLACEHOLDER = /^<name the consequence/i;
|
|
84
|
+
function placeholderValue(input) {
|
|
85
|
+
if ('object' !== typeof input || null === input)
|
|
86
|
+
return undefined;
|
|
87
|
+
const value = input['value'];
|
|
88
|
+
return 'string' === typeof value && UNFILLED_PLACEHOLDER.test(value) ? value : undefined;
|
|
89
|
+
}
|
|
70
90
|
export function parsePredicate(input) {
|
|
91
|
+
const unfilled = placeholderValue(input);
|
|
92
|
+
if (unfilled !== undefined) {
|
|
93
|
+
throw new Error(`that predicate still carries the placeholder from verify_next ("${unfilled}"). Nothing ran, ` +
|
|
94
|
+
'and no verdict was produced — which is deliberate: as written it would have failed and ' +
|
|
95
|
+
'blamed the app for a value you had not filled in yet. Replace it with the consequence ' +
|
|
96
|
+
'this action actually causes, and prefer one the action CHANGES: a signal, a request, a ' +
|
|
97
|
+
'route, or store state. Text on screen that was already there proves nothing.');
|
|
98
|
+
}
|
|
71
99
|
const parsed = PredicateSchema.safeParse(input);
|
|
72
100
|
if (parsed.success)
|
|
73
101
|
return parsed.data;
|
|
@@ -78,7 +106,7 @@ export function parsePredicate(input) {
|
|
|
78
106
|
// back is how the zod array became unreadable in the first place.
|
|
79
107
|
const issues = parsed.error.issues.slice(0, 3).map(describeIssue).join('; ');
|
|
80
108
|
throw new Error(`that predicate did not parse (kind "${kind}"): ${issues}. Nothing ran — the predicate was ` +
|
|
81
|
-
`not evaluated, so no verdict was produced. ${accepted(kind)} ` +
|
|
109
|
+
`not evaluated, so no verdict was produced. ${accepted(kind, parsed.error.issues)} ` +
|
|
82
110
|
`A valid ${kind} predicate looks like: ${exampleFor(kind)}`);
|
|
83
111
|
}
|
|
84
112
|
/**
|
|
@@ -88,12 +116,24 @@ export function parsePredicate(input) {
|
|
|
88
116
|
* round trip on the one call path that produces verdicts. Naming the accepted fields — or, when the
|
|
89
117
|
* kind itself is the mistake, the accepted kinds — makes the retry informed instead.
|
|
90
118
|
*/
|
|
91
|
-
function accepted(kind) {
|
|
119
|
+
function accepted(kind, issues) {
|
|
92
120
|
const fields = predicateFieldsFor(kind);
|
|
93
121
|
if (0 < fields.length) {
|
|
94
122
|
// A field whose value is an object is the one an agent cannot guess from its name alone, so
|
|
95
123
|
// expand it in the same breath rather than making the shape a second round trip.
|
|
96
|
-
|
|
124
|
+
//
|
|
125
|
+
// Only the field the rejection actually points at, though. Expanding every object-valued field
|
|
126
|
+
// of the kind makes the sentence longer the more the schema grows, and buries the one clause
|
|
127
|
+
// that answers the question asked — the same argument this file already makes for showing one
|
|
128
|
+
// example per kind instead of a generic one. `element` is the only kind with an object-valued
|
|
129
|
+
// field today, so the two agree except when the mistake is somewhere else entirely, which is
|
|
130
|
+
// the case pinned in the tests.
|
|
131
|
+
const all = predicateNestedFieldsFor(kind);
|
|
132
|
+
const blamed = new Set(issues
|
|
133
|
+
.map((issue) => issue.path[0])
|
|
134
|
+
.filter((field) => 'string' === typeof field));
|
|
135
|
+
const nested = Object.entries(all)
|
|
136
|
+
.filter(([field]) => blamed.has(field))
|
|
97
137
|
.map(([field, keys]) => ` ${field} accepts: ${keys.join(', ')}.`)
|
|
98
138
|
.join('');
|
|
99
139
|
return `${kind} accepts: ${fields.join(', ')}.${nested}`;
|
|
@@ -56,6 +56,11 @@ export type Predicate = {
|
|
|
56
56
|
kind: typeof PredicateKind.SIGNAL;
|
|
57
57
|
name?: string;
|
|
58
58
|
dataMatches?: Record<string, unknown>;
|
|
59
|
+
/**
|
|
60
|
+
* Exact number of matching signals in the window — the same cardinality assertion `net`
|
|
61
|
+
* carries, on the channel the app itself speaks. Omit for presence (≥1).
|
|
62
|
+
*/
|
|
63
|
+
count?: number;
|
|
59
64
|
since?: number;
|
|
60
65
|
} | {
|
|
61
66
|
kind: typeof PredicateKind.STATE;
|
|
@@ -103,6 +108,45 @@ export declare const PredicateSchema: z.ZodType<Predicate>;
|
|
|
103
108
|
* wall. Empty for a kind that is not in the union.
|
|
104
109
|
*/
|
|
105
110
|
export declare function predicateFieldsFor(kind: string): readonly string[];
|
|
111
|
+
/**
|
|
112
|
+
* Is this tool parameter ACTUALLY the predicate union?
|
|
113
|
+
*
|
|
114
|
+
* Not a name check. `until` is overloaded on this surface — the act/assert family means a predicate
|
|
115
|
+
* by it, while reticle_observe / _network / _console mean a NUMBER, an upper cursor bound — so any
|
|
116
|
+
* caller keying on the word would treat a numeric parameter as a predicate and describe it to the
|
|
117
|
+
* agent as an object. Keying on the schema cannot make that mistake.
|
|
118
|
+
*
|
|
119
|
+
* Lives beside the schema because two callers now ask the question: the lean tool surface, which
|
|
120
|
+
* compacts these parameters, and `reticle_tools`, which spells their grammar out on request. Two
|
|
121
|
+
* spellings of "is this a predicate" is one chance for the surface and the grammar to disagree.
|
|
122
|
+
*/
|
|
123
|
+
export declare function isPredicateParam(schema: z.ZodTypeAny): boolean;
|
|
124
|
+
/**
|
|
125
|
+
* Every kind's fields, in one block an agent can write a predicate from.
|
|
126
|
+
*
|
|
127
|
+
* The tool surface advertises the KIND list and points at `reticle_tools` for the fields, because
|
|
128
|
+
* inlining the 12-variant recursive union in the declared JSON Schema costs thousands of characters
|
|
129
|
+
* per predicate parameter, re-sent every turn, to describe a grammar most calls use one variant of.
|
|
130
|
+
* That trade is right — but the pointer has to land somewhere, and it landed on a parameter
|
|
131
|
+
* description reading "same shape as reticle_assert". So the grammar was reachable from nothing, and
|
|
132
|
+
* an agent that could not find `route`'s fields fell back to a `text` check where a route check was
|
|
133
|
+
* meant: the weaker oracle, which is the expensive half of an undiscoverable grammar.
|
|
134
|
+
*
|
|
135
|
+
* Derived from the schema, like `predicateFieldsFor` and for the same reason: a hand-written grammar
|
|
136
|
+
* that drifts is worse than none, because the agent trusts it and retries into the same wall.
|
|
137
|
+
*/
|
|
138
|
+
export declare function predicateGrammar(): Readonly<Record<string, string>>;
|
|
139
|
+
/**
|
|
140
|
+
* The keys of a field that is an object, once its wrappers are peeled. Empty for anything else.
|
|
141
|
+
*
|
|
142
|
+
* Exported so the wrapper handling is asserted against THIS function rather than through the
|
|
143
|
+
* rendered sentence. Reached only that way, a wrapper it fails to peel returns `[]`, the old
|
|
144
|
+
* message prints unchanged, and nothing reddens — a regression with no symptom.
|
|
145
|
+
*
|
|
146
|
+
* No top-level predicate field is currently declared behind a wrapper, so the real schema exercises
|
|
147
|
+
* none of these paths; that is exactly why they are worth a case each.
|
|
148
|
+
*/
|
|
149
|
+
export declare function nestedKeysOf(schema: z.ZodTypeAny | undefined): readonly string[];
|
|
106
150
|
/**
|
|
107
151
|
* One level into the fields that are themselves objects — `{ query: ['by', 'value', ...] }`.
|
|
108
152
|
*
|