@reticlehq/server 3.8.0 → 3.8.2
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 +2 -2
- package/dist/command/cli/answers/gate-hook-message.d.ts +2 -0
- package/dist/command/cli/answers/gate-hook-message.js +5 -1
- package/dist/command/cli/auth/cloud-session.d.ts +1 -19
- package/dist/command/cli/auth/device-flow.d.ts +3 -37
- package/dist/command/cli/cli-doctor.js +30 -23
- package/dist/command/cli/cli-flow-commands.js +7 -1
- package/dist/command/cli/cli-parse-serve.d.ts +24 -0
- package/dist/command/cli/cli-parse-serve.js +124 -0
- package/dist/command/cli/cli-parse.d.ts +1 -10
- package/dist/command/cli/cli-parse.js +62 -121
- package/dist/command/cli/cli-usage.js +5 -2
- package/dist/command/cli/cloud-link.d.ts +1 -20
- package/dist/command/cli/doctor/doctor-agent-link.d.ts +23 -0
- package/dist/command/cli/doctor/doctor-agent-link.js +28 -0
- package/dist/command/cli/ports/port-holder.js +6 -1
- package/dist/command/cli/project-name.d.ts +1 -7
- package/dist/command/cli/project-name.js +1 -0
- package/dist/command/daemon/daemon.d.ts +6 -0
- package/dist/command/daemon/daemon.js +52 -2
- package/dist/command/daemon/win32-escape.d.ts +32 -0
- package/dist/command/daemon/win32-escape.js +119 -0
- package/dist/command/setup/bringup/dev-server-files.d.ts +1 -9
- package/dist/command/setup/bringup/dev-server-wait.js +5 -1
- package/dist/command/version/version-skew.d.ts +2 -0
- package/dist/command/version/version-skew.js +7 -3
- package/dist/features/crawl/crawl-tools.js +2 -2
- package/dist/features/exhaust/ledger.d.ts +15 -96
- package/dist/features/exhaust/ledger.js +3 -3
- package/dist/features/license/license.d.ts +2 -14
- package/dist/hooks/hook-commands.js +19 -9
- package/dist/judgement/capsule/capsule-store.d.ts +2 -39
- package/dist/language/flows/annotate-tools.js +1 -1
- package/dist/language/flows/fields/flow-secret-field.js +1 -1
- package/dist/language/flows/flow-expect-grammar.js +14 -6
- package/dist/language/flows/flow-lease.d.ts +27 -0
- package/dist/language/flows/flow-lease.js +59 -0
- package/dist/language/flows/flow-replay-run.d.ts +1 -87
- package/dist/language/flows/flow-replay-run.js +88 -283
- package/dist/language/flows/flow-replayable.d.ts +1 -1
- package/dist/language/flows/flow-start-path.d.ts +108 -0
- package/dist/language/flows/flow-start-path.js +313 -0
- package/dist/language/flows/flow-tools.d.ts +2 -23
- package/dist/language/flows/flow-tools.js +34 -66
- package/dist/language/flows/recording/flake.d.ts +3 -27
- package/dist/language/flows/recording/flake.js +1 -1
- package/dist/language/flows/stores/assertion-tiers-store.js +1 -1
- package/dist/machine/git-tracks.js +1 -0
- package/dist/memory/cloud/cloud-sync.d.ts +2 -24
- package/dist/memory/intent/intent-shard.d.ts +41 -266
- package/dist/memory/journal/envelope-store.js +1 -1
- package/dist/memory/journal/envelope.d.ts +6 -154
- package/dist/memory/journal/on-disk/ambient-file.d.ts +1 -7
- package/dist/memory/journal/on-disk/ambient-file.js +1 -1
- package/dist/memory/journal/self-observation.js +3 -1
- package/dist/memory/journal/session-journal.js +39 -20
- package/dist/portal/input/drive-error.d.ts +6 -0
- package/dist/portal/input/drive-error.js +9 -0
- package/dist/portal/input/real-input-press.d.ts +34 -0
- package/dist/portal/input/real-input-press.js +150 -0
- package/dist/portal/input/real-input.d.ts +6 -23
- package/dist/portal/input/real-input.js +95 -192
- package/dist/portal/input/viewport-tools.d.ts +19 -15
- package/dist/portal/pool/browser-pool.d.ts +5 -55
- package/dist/portal/pool/browser-pool.js +121 -100
- package/dist/portal/pool/pool-lease.d.ts +128 -0
- package/dist/portal/pool/pool-lease.js +49 -0
- package/dist/portal/production/production-activation.d.ts +5 -27
- package/dist/portal/session/control-envelope.d.ts +1 -1
- package/dist/portal/session/zero-install-first-call.d.ts +3 -0
- package/dist/portal/session/zero-install-first-call.js +33 -12
- package/dist/surface/mcp/mcp.d.ts +3 -3
- package/dist/surface/mcp/mcp.js +4 -6
- package/dist/surface/tools/act/act-danger.js +32 -17
- package/dist/surface/tools/act/durable.js +1 -1
- package/dist/surface/tools/act-sequence-tool.js +3 -3
- package/dist/surface/tools/act-tools.js +15 -16
- package/dist/surface/tools/agent/compact-envelope.d.ts +2 -2
- package/dist/surface/tools/coverage-tools.js +1 -1
- package/dist/surface/tools/dynamic-tools.d.ts +8 -0
- package/dist/surface/tools/dynamic-tools.js +36 -16
- package/dist/surface/tools/empty-session-list.d.ts +5 -29
- package/dist/surface/tools/feedback-tools.js +1 -1
- package/dist/surface/tools/gap-novelty.d.ts +1 -1
- package/dist/surface/tools/gaps/capability-absences.js +4 -3
- package/dist/surface/tools/harness-gate.d.ts +1 -1
- package/dist/surface/tools/harness-toolset.js +3 -4
- package/dist/surface/tools/lease-permissions.d.ts +1 -1
- package/dist/surface/tools/lease-readiness.d.ts +23 -1
- package/dist/surface/tools/lease-readiness.js +75 -9
- package/dist/surface/tools/lease-tools.d.ts +1 -52
- package/dist/surface/tools/lease-tools.js +79 -142
- package/dist/surface/tools/observe-tools.js +3 -1
- package/dist/surface/tools/read-tools.js +5 -5
- package/dist/surface/tools/real-input-attempt.js +70 -31
- package/dist/surface/tools/session-ports.d.ts +57 -0
- package/dist/surface/tools/session-ports.js +102 -0
- package/dist/surface/tools/tool-kit.d.ts +5 -3
- package/dist/surface/tools/tools.js +1 -1
- package/dist/telemetry/daemon-telemetry.d.ts +3 -1
- package/dist/telemetry/daemon-telemetry.js +9 -2
- package/dist/telemetry/telemetry.d.ts +3 -1
- package/dist/telemetry/telemetry.js +2 -0
- package/docs/agent-cheatsheet.md +12 -0
- package/docs/cli/gate.mdx +1 -1
- package/docs/cli/verify.mdx +8 -1
- package/docs/first-drive.md +7 -0
- package/docs/multi-agent-testing.md +2 -0
- package/docs/packages/react.mdx +14 -2
- package/docs/packages/vite-plugin.mdx +1 -0
- package/docs/predicates.mdx +6 -0
- package/docs/skill.md +3 -3
- package/docs/troubleshooting.mdx +10 -0
- package/docs/usage.md +1 -1
- package/package.json +8 -9
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* the base64 4/3 inflation factor applied, so the encoded payload always fits in one WebSocket
|
|
20
20
|
* frame.
|
|
21
21
|
*/
|
|
22
|
-
import { ActionType, DriveErrorCode, ElementState, INSPECT_IN_VIEWPORT_FIELD, INSPECT_REVEAL_ARG, InputMode, InputModeReason, ReticleCommand, TRANSPORT_LIMITS, } from '@reticlehq/core';
|
|
22
|
+
import { ActionType, clickInputProblem, DriveErrorCode, ElementState, INSPECT_IN_VIEWPORT_FIELD, INSPECT_REVEAL_ARG, InputMode, InputModeReason, ReticleCommand, TRANSPORT_LIMITS, } from '@reticlehq/core';
|
|
23
23
|
import { boxCenter, DriveError, isRealInputAction, unspellablePressReason, } from '../../portal/input/real-input.js';
|
|
24
24
|
import { assertDragNotDestructive, assertNotDestructive } from './act/act-danger.js';
|
|
25
25
|
import { assertNativeAllowed } from '../../portal/production/production-drive.js';
|
|
@@ -101,10 +101,12 @@ const UPLOAD_MAX_BYTES = Math.floor((TRANSPORT_LIMITS.MAX_MESSAGE_BYTES / (4 / 3
|
|
|
101
101
|
* through unchanged. `home` is injected so unit tests do not depend on the machine's real home.
|
|
102
102
|
*/
|
|
103
103
|
function expandUserPath(rawPath, home) {
|
|
104
|
-
if ('~' === rawPath)
|
|
104
|
+
if ('~' === rawPath) {
|
|
105
105
|
return home;
|
|
106
|
-
|
|
106
|
+
}
|
|
107
|
+
if (rawPath.startsWith('~/') || rawPath.startsWith('~\\')) {
|
|
107
108
|
return join(home, rawPath.slice(2));
|
|
109
|
+
}
|
|
108
110
|
return rawPath;
|
|
109
111
|
}
|
|
110
112
|
/** True when `real` is inside `root` after both have been realpath'd / resolved. */
|
|
@@ -132,15 +134,18 @@ async function readUploadRoots(projectRoot, fs, home) {
|
|
|
132
134
|
catch {
|
|
133
135
|
return [];
|
|
134
136
|
}
|
|
135
|
-
if ('object' !== typeof parsed || null === parsed || Array.isArray(parsed))
|
|
137
|
+
if ('object' !== typeof parsed || null === parsed || Array.isArray(parsed)) {
|
|
136
138
|
return [];
|
|
139
|
+
}
|
|
137
140
|
const listed = parsed['uploadRoots'];
|
|
138
|
-
if (!Array.isArray(listed))
|
|
141
|
+
if (!Array.isArray(listed)) {
|
|
139
142
|
return [];
|
|
143
|
+
}
|
|
140
144
|
const roots = [];
|
|
141
145
|
for (const entry of listed) {
|
|
142
|
-
if ('string' !== typeof entry || 0 === entry.trim().length)
|
|
146
|
+
if ('string' !== typeof entry || 0 === entry.trim().length) {
|
|
143
147
|
continue;
|
|
148
|
+
}
|
|
144
149
|
roots.push(resolve(expandUserPath(entry.trim(), home)));
|
|
145
150
|
}
|
|
146
151
|
return roots;
|
|
@@ -191,11 +196,13 @@ async function resolveUploadPath(rawPath, projectRoot, fs, uploadRoots, home) {
|
|
|
191
196
|
export async function rewriteUploadArgs(deps, action, innerArgs,
|
|
192
197
|
/** Injected for tests; production uses the process home directory. */
|
|
193
198
|
home = homedir()) {
|
|
194
|
-
if (action !== ActionType.UPLOAD)
|
|
199
|
+
if (action !== ActionType.UPLOAD) {
|
|
195
200
|
return innerArgs;
|
|
201
|
+
}
|
|
196
202
|
const rawPath = asString(innerArgs['path']);
|
|
197
|
-
if (rawPath === undefined)
|
|
198
|
-
return innerArgs;
|
|
203
|
+
if (rawPath === undefined) {
|
|
204
|
+
return innerArgs;
|
|
205
|
+
} // no path → browser handles as inline upload
|
|
199
206
|
// Project root is one level above .reticle/
|
|
200
207
|
const projectRoot = resolve(join(deps.reticleRoot, '..'));
|
|
201
208
|
const uploadRoots = await readUploadRoots(projectRoot, deps.fs, home);
|
|
@@ -236,14 +243,17 @@ function synthetic(reason) {
|
|
|
236
243
|
function performArgsOf(inner) {
|
|
237
244
|
const performArgs = {};
|
|
238
245
|
const value = asString(inner['value']);
|
|
239
|
-
if (value !== undefined)
|
|
246
|
+
if (value !== undefined) {
|
|
240
247
|
performArgs.value = value;
|
|
248
|
+
}
|
|
241
249
|
const text = asString(inner['text']);
|
|
242
|
-
if (text !== undefined)
|
|
250
|
+
if (text !== undefined) {
|
|
243
251
|
performArgs.text = text;
|
|
252
|
+
}
|
|
244
253
|
const key = asString(inner['key']);
|
|
245
|
-
if (key !== undefined)
|
|
254
|
+
if (key !== undefined) {
|
|
246
255
|
performArgs.key = key;
|
|
256
|
+
}
|
|
247
257
|
const modifiers = inner['modifiers'];
|
|
248
258
|
if (Array.isArray(modifiers)) {
|
|
249
259
|
performArgs.modifiers = modifiers.filter((m) => 'string' === typeof m);
|
|
@@ -251,9 +261,14 @@ function performArgsOf(inner) {
|
|
|
251
261
|
// A held key is the whole point of a hold-to-confirm control, and the synthetic path has honoured
|
|
252
262
|
// `holdMs` all along. Not forwarding it made the real keyboard the WEAKER of the two paths: it
|
|
253
263
|
// drove the key instantly and reported a hold that never held.
|
|
264
|
+
const button = asString(inner['button']);
|
|
265
|
+
if (button !== undefined) {
|
|
266
|
+
performArgs.button = button;
|
|
267
|
+
}
|
|
254
268
|
const holdMs = asNumber(inner['holdMs']);
|
|
255
|
-
if (holdMs !== undefined)
|
|
269
|
+
if (holdMs !== undefined) {
|
|
256
270
|
performArgs.holdMs = holdMs;
|
|
271
|
+
}
|
|
257
272
|
// `keys` and `code` are read but not forwarded: both are routed away by the caller — a multi-key
|
|
258
273
|
// sequence and an explicit physical code have no driver spelling — so carrying them into the
|
|
259
274
|
// provider would only invite it to guess at a gesture the caller did not ask for.
|
|
@@ -285,11 +300,13 @@ const ANCHOR_FIELDS = [
|
|
|
285
300
|
*/
|
|
286
301
|
async function pressOnLease(deps, session, ref, inner) {
|
|
287
302
|
const pool = deps.pool;
|
|
288
|
-
if (pool === undefined || !pool.hasKeyboard(session.id))
|
|
303
|
+
if (pool === undefined || !pool.hasKeyboard(session.id)) {
|
|
289
304
|
return undefined;
|
|
305
|
+
}
|
|
290
306
|
const unspellable = unspellablePressReason('', inner);
|
|
291
|
-
if (unspellable !== undefined)
|
|
307
|
+
if (unspellable !== undefined) {
|
|
292
308
|
return synthetic(unspellable);
|
|
309
|
+
}
|
|
293
310
|
// What the focused element is called, from the focus act's own report, so the saved step can
|
|
294
311
|
// find it again: the real keyboard reports no element of its own.
|
|
295
312
|
let anchor = {};
|
|
@@ -307,8 +324,9 @@ async function pressOnLease(deps, session, ref, inner) {
|
|
|
307
324
|
}
|
|
308
325
|
}
|
|
309
326
|
const performed = await pool.pressLease(session.id, performArgsOf(inner));
|
|
310
|
-
if (performed?.performed !== true)
|
|
327
|
+
if (performed?.performed !== true) {
|
|
311
328
|
return undefined;
|
|
329
|
+
}
|
|
312
330
|
return {
|
|
313
331
|
result: {
|
|
314
332
|
...anchor,
|
|
@@ -382,15 +400,17 @@ async function hoverForReal(deps, session, ref, inner, provider) {
|
|
|
382
400
|
if (box === undefined) {
|
|
383
401
|
throw new Error(HOVER_NEEDS_POINTER_MSG);
|
|
384
402
|
}
|
|
385
|
-
if (!inView)
|
|
403
|
+
if (!inView) {
|
|
386
404
|
throw new Error(HOVER_OFF_VIEWPORT_MSG);
|
|
405
|
+
}
|
|
387
406
|
if (providerReady && provider !== undefined) {
|
|
388
407
|
try {
|
|
389
408
|
const performed = await provider.perform(session.url, ActionType.HOVER, box, {});
|
|
390
409
|
// Hover always has a resolved box, so there is always a center to report: the provider's own
|
|
391
410
|
// reading, or the box's if it reported none. Both are the same point, not a fabricated one.
|
|
392
|
-
if (performed.performed)
|
|
411
|
+
if (performed.performed) {
|
|
393
412
|
return hoverSucceeded(performed.center ?? boxCenter(box), scrolled);
|
|
413
|
+
}
|
|
394
414
|
}
|
|
395
415
|
catch {
|
|
396
416
|
// A provider error used to fall back to synthetic dispatch. For hover that is a lie.
|
|
@@ -428,8 +448,9 @@ export async function tryRealInput(deps, session, ref, action, args) {
|
|
|
428
448
|
if (provider === undefined) {
|
|
429
449
|
if (action === ActionType.PRESS) {
|
|
430
450
|
const leased = await pressOnLease(deps, session, ref, inner);
|
|
431
|
-
if (leased !== undefined)
|
|
451
|
+
if (leased !== undefined) {
|
|
432
452
|
return leased;
|
|
453
|
+
}
|
|
433
454
|
}
|
|
434
455
|
// Silent by default: with no provider EVERY action is synthetic, and a reason on all of them is
|
|
435
456
|
// noise on the most-used tool in the product. But an agent that passed native:true asked a
|
|
@@ -441,11 +462,13 @@ export async function tryRealInput(deps, session, ref, action, args) {
|
|
|
441
462
|
// one was asked for, instead of a different gesture being sent. See `unspellablePressReason`.
|
|
442
463
|
if (action === ActionType.PRESS) {
|
|
443
464
|
const unspellable = unspellablePressReason(ref, inner);
|
|
444
|
-
if (unspellable !== undefined)
|
|
465
|
+
if (unspellable !== undefined) {
|
|
445
466
|
return synthetic(unspellable);
|
|
467
|
+
}
|
|
446
468
|
}
|
|
447
|
-
if (!isRealInputAction(action))
|
|
448
|
-
return synthetic(InputModeReason.NOT_POINTER);
|
|
469
|
+
if (!isRealInputAction(action)) {
|
|
470
|
+
return synthetic(InputModeReason.NOT_POINTER);
|
|
471
|
+
} // fill/type stay synthetic
|
|
449
472
|
// "Don't click, run the code": a click/dblclick runs the occlusion-honest SYNTHETIC path by default
|
|
450
473
|
// even with a provider configured — no coordinate gesture to be intercepted by the HUD or missed
|
|
451
474
|
// off-screen. Opt into a trusted native click with args.native:true (file pickers, clipboard,
|
|
@@ -453,6 +476,13 @@ export async function tryRealInput(deps, session, ref, action, args) {
|
|
|
453
476
|
if ((action === ActionType.CLICK || action === ActionType.DBLCLICK) && !askedForNative) {
|
|
454
477
|
return synthetic(InputModeReason.SYNTHETIC_CLICK_PREFERRED);
|
|
455
478
|
}
|
|
479
|
+
// The page refuses the same arguments on the synthetic path. A native click must not drop one.
|
|
480
|
+
if (action === ActionType.CLICK || action === ActionType.DBLCLICK) {
|
|
481
|
+
const problem = clickInputProblem(action, inner);
|
|
482
|
+
if (problem !== undefined) {
|
|
483
|
+
throw new Error(`cannot ${action}: ${problem}`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
456
486
|
// Under version skew CDP is unusable while DOM tools still work (#688). Surface the skew sentence
|
|
457
487
|
// rather than "page not correlated" / a silent provider-error fallback — those send the agent
|
|
458
488
|
// hunting a dead context that is not dead.
|
|
@@ -466,15 +496,17 @@ export async function tryRealInput(deps, session, ref, action, args) {
|
|
|
466
496
|
if (action !== ActionType.PRESS && session.versionSkew !== undefined) {
|
|
467
497
|
throw new Error(session.versionSkew);
|
|
468
498
|
}
|
|
469
|
-
if (!(await provider.isAvailableFor(session.url)))
|
|
499
|
+
if (!(await provider.isAvailableFor(session.url))) {
|
|
470
500
|
return synthetic(InputModeReason.PAGE_NOT_CORRELATED);
|
|
501
|
+
}
|
|
471
502
|
const performArgs = performArgsOf(inner);
|
|
472
503
|
// A document key has no element of its own: skip INSPECT and the box entirely.
|
|
473
504
|
if (action === ActionType.PRESS) {
|
|
474
505
|
try {
|
|
475
506
|
const performed = await provider.perform(session.url, action, PRESS_HAS_NO_BOX, performArgs);
|
|
476
|
-
if (!performed.performed)
|
|
507
|
+
if (!performed.performed) {
|
|
477
508
|
return synthetic(InputModeReason.PROVIDER_DECLINED);
|
|
509
|
+
}
|
|
478
510
|
// No `center`: a key addresses focus, not a point. The placeholder's zero happened nowhere.
|
|
479
511
|
//
|
|
480
512
|
// `heldMs` IS forwarded, and it is the whole reason this object is not just the three fields
|
|
@@ -521,13 +553,15 @@ export async function tryRealInput(deps, session, ref, action, args) {
|
|
|
521
553
|
let box = asBox(inspected);
|
|
522
554
|
// A box outside the viewport after the reveal is a gesture that would land on nothing. The
|
|
523
555
|
// synthetic path scrolls and hit-tests on its own, so it is the honest answer.
|
|
524
|
-
if (box === undefined || !source.inView)
|
|
556
|
+
if (box === undefined || !source.inView) {
|
|
525
557
|
return synthetic(InputModeReason.ELEMENT_NOT_LOCATABLE);
|
|
558
|
+
}
|
|
526
559
|
let toBox;
|
|
527
560
|
if (action === ActionType.DRAG) {
|
|
528
561
|
const toRef = asString(inner['toRef']);
|
|
529
|
-
if (toRef === undefined)
|
|
562
|
+
if (toRef === undefined) {
|
|
530
563
|
return synthetic(InputModeReason.DRAG_TARGET_UNRESOLVED);
|
|
564
|
+
}
|
|
531
565
|
const target = await inspectRevealed(deps, session, toRef);
|
|
532
566
|
const targetInspected = target.inspected;
|
|
533
567
|
// A drag is judged on BOTH ends: dropping onto "Trash" is destructive however innocent the
|
|
@@ -549,12 +583,14 @@ export async function tryRealInput(deps, session, ref, action, args) {
|
|
|
549
583
|
}
|
|
550
584
|
}
|
|
551
585
|
}
|
|
552
|
-
if (toBox !== undefined)
|
|
586
|
+
if (toBox !== undefined) {
|
|
553
587
|
performArgs.toBox = toBox;
|
|
588
|
+
}
|
|
554
589
|
try {
|
|
555
590
|
const performed = await provider.perform(session.url, action, box, performArgs);
|
|
556
|
-
if (!performed.performed)
|
|
591
|
+
if (!performed.performed) {
|
|
557
592
|
return synthetic(InputModeReason.PROVIDER_DECLINED);
|
|
593
|
+
}
|
|
558
594
|
return {
|
|
559
595
|
result: {
|
|
560
596
|
performed: true,
|
|
@@ -566,8 +602,9 @@ export async function tryRealInput(deps, session, ref, action, args) {
|
|
|
566
602
|
};
|
|
567
603
|
}
|
|
568
604
|
catch {
|
|
569
|
-
if (session.versionSkew !== undefined)
|
|
605
|
+
if (session.versionSkew !== undefined) {
|
|
570
606
|
throw new Error(session.versionSkew);
|
|
607
|
+
}
|
|
571
608
|
return {
|
|
572
609
|
result: undefined,
|
|
573
610
|
settled: false,
|
|
@@ -585,8 +622,9 @@ export async function tryRealInput(deps, session, ref, action, args) {
|
|
|
585
622
|
* A Proxy, not a copy: the session keeps private state its own methods read.
|
|
586
623
|
*/
|
|
587
624
|
export function withLeaseKeyboard(deps, session) {
|
|
588
|
-
if (deps.realInput !== undefined || deps.pool?.hasKeyboard(session.id) !== true)
|
|
625
|
+
if (deps.realInput !== undefined || deps.pool?.hasKeyboard(session.id) !== true) {
|
|
589
626
|
return session;
|
|
627
|
+
}
|
|
590
628
|
const command = async (name, args = {}, timeoutMs) => {
|
|
591
629
|
if (ReticleCommand.ACT === name && ActionType.PRESS === args['action']) {
|
|
592
630
|
const ref = asString(args['ref']) ?? '';
|
|
@@ -600,8 +638,9 @@ export function withLeaseKeyboard(deps, session) {
|
|
|
600
638
|
};
|
|
601
639
|
return new Proxy(session, {
|
|
602
640
|
get(target, key) {
|
|
603
|
-
if ('command' === key)
|
|
641
|
+
if ('command' === key) {
|
|
604
642
|
return command;
|
|
643
|
+
}
|
|
605
644
|
const value = Reflect.get(target, key, target);
|
|
606
645
|
return 'function' === typeof value
|
|
607
646
|
? value.bind(target)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider capabilities handed to features as ports: a suite fixture to seed from, save/restore,
|
|
3
|
+
* slowing the page and breaking it. Each is `undefined` when the provider cannot do it. They sit on
|
|
4
|
+
* the tool side because a feature reaching into the input layer directly would depend on a driver.
|
|
5
|
+
*/
|
|
6
|
+
import type { RealInputProvider } from '../../portal/input/real-input.js';
|
|
7
|
+
import { type StorageFixturePort } from '../../portal/input/storage-fixture.js';
|
|
8
|
+
import { type NetworkMutationPort } from '../../portal/input/network-mutation.js';
|
|
9
|
+
import type { Perturbation, SeedStorage } from '@reticlehq/core';
|
|
10
|
+
/**
|
|
11
|
+
* The state a suite should boot its flows from: whatever the agent is already sitting in.
|
|
12
|
+
*
|
|
13
|
+
* This is the join the fixture work was building toward. A suite's flows each start from cold, so
|
|
14
|
+
* fifty of them prove the login works fifty times — and the flow that LOGS OUT leaves every flow
|
|
15
|
+
* after it signed out, which no navigation repairs, because the problem is not where the subject is
|
|
16
|
+
* but what it holds. A seed is applied to an isolated context BEFORE the first navigation, which is
|
|
17
|
+
* exactly and only when a fixture can work.
|
|
18
|
+
*
|
|
19
|
+
* Best-effort, deliberately. A fixture is an OPTIMISATION over running from cold, and running from
|
|
20
|
+
* cold is correct: a suite that refused to start because a cookie jar could not be read would have
|
|
21
|
+
* traded a slow answer for no answer. Every "no" here — no provider, no storage support, nothing
|
|
22
|
+
* driven, a browser that threw, nothing worth seeding — produces the same honest `undefined`, and
|
|
23
|
+
* the suite runs exactly as it did before any of this existed.
|
|
24
|
+
*/
|
|
25
|
+
export declare function suiteFixtureSeed(realInput: RealInputProvider | undefined, appUrl: string): Promise<SeedStorage | undefined>;
|
|
26
|
+
/**
|
|
27
|
+
* A way to make this session's page SLOW on purpose, or nothing.
|
|
28
|
+
*
|
|
29
|
+
* The third of these seams, for the same reason as the other two: a feature reaching into the input
|
|
30
|
+
* layer directly is a feature depending on a driver, and the boundary guard asks about it.
|
|
31
|
+
*
|
|
32
|
+
* Installs delays and NOTHING else. A rule that carried a status or a body would substitute a
|
|
33
|
+
* response the server never sent, and an app raced against invented data produces invented races —
|
|
34
|
+
* a fabricated defect handed to somebody at 3am, which is worse than finding nothing.
|
|
35
|
+
*/
|
|
36
|
+
export declare function sessionPerturbationPort(realInput: RealInputProvider | undefined, appUrl: string | undefined): Promise<{
|
|
37
|
+
slow(rules: readonly Perturbation[]): Promise<void>;
|
|
38
|
+
clear(): Promise<void>;
|
|
39
|
+
} | undefined>;
|
|
40
|
+
/**
|
|
41
|
+
* Save/restore for this session's page, or nothing.
|
|
42
|
+
*
|
|
43
|
+
* The same job as `suiteFixtureSeed` next door, stopping one step earlier: that one captures and
|
|
44
|
+
* converts, this hands back the PORT so a caller can re-apply what it captured later. It lives here
|
|
45
|
+
* for the reason written above — a feature reaching into the input layer directly is a feature
|
|
46
|
+
* depending on a driver, and the boundary guard asks about it. It asked.
|
|
47
|
+
*/
|
|
48
|
+
export declare function suiteFixturePort(realInput: RealInputProvider | undefined, appUrl: string | undefined): Promise<StorageFixturePort | undefined>;
|
|
49
|
+
/**
|
|
50
|
+
* A way to break this session's page, or nothing.
|
|
51
|
+
*
|
|
52
|
+
* Sits beside `suiteFixtureSeed` because it is the same shape of job: turning a provider capability
|
|
53
|
+
* into something a feature can use, on the one side of the graph that is allowed to know about both.
|
|
54
|
+
* A flow tool reaching into the input layer directly would be a feature depending on a driver, which
|
|
55
|
+
* is the reach the boundary guard exists to ask about.
|
|
56
|
+
*/
|
|
57
|
+
export declare function sessionMutationPort(realInput: RealInputProvider | undefined, appUrl: string | undefined): Promise<NetworkMutationPort | undefined>;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { fixturePortFor, seedFromStorageState, } from '../../portal/input/storage-fixture.js';
|
|
2
|
+
import { mutationPortFor } from '../../portal/input/network-mutation.js';
|
|
3
|
+
/**
|
|
4
|
+
* The state a suite should boot its flows from: whatever the agent is already sitting in.
|
|
5
|
+
*
|
|
6
|
+
* This is the join the fixture work was building toward. A suite's flows each start from cold, so
|
|
7
|
+
* fifty of them prove the login works fifty times — and the flow that LOGS OUT leaves every flow
|
|
8
|
+
* after it signed out, which no navigation repairs, because the problem is not where the subject is
|
|
9
|
+
* but what it holds. A seed is applied to an isolated context BEFORE the first navigation, which is
|
|
10
|
+
* exactly and only when a fixture can work.
|
|
11
|
+
*
|
|
12
|
+
* Best-effort, deliberately. A fixture is an OPTIMISATION over running from cold, and running from
|
|
13
|
+
* cold is correct: a suite that refused to start because a cookie jar could not be read would have
|
|
14
|
+
* traded a slow answer for no answer. Every "no" here — no provider, no storage support, nothing
|
|
15
|
+
* driven, a browser that threw, nothing worth seeding — produces the same honest `undefined`, and
|
|
16
|
+
* the suite runs exactly as it did before any of this existed.
|
|
17
|
+
*/
|
|
18
|
+
export async function suiteFixtureSeed(realInput, appUrl) {
|
|
19
|
+
if (realInput === undefined) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const port = await fixturePortFor(realInput, appUrl);
|
|
24
|
+
if (port === undefined) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
return seedFromStorageState(await port.capture(), appUrl);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* A way to make this session's page SLOW on purpose, or nothing.
|
|
35
|
+
*
|
|
36
|
+
* The third of these seams, for the same reason as the other two: a feature reaching into the input
|
|
37
|
+
* layer directly is a feature depending on a driver, and the boundary guard asks about it.
|
|
38
|
+
*
|
|
39
|
+
* Installs delays and NOTHING else. A rule that carried a status or a body would substitute a
|
|
40
|
+
* response the server never sent, and an app raced against invented data produces invented races —
|
|
41
|
+
* a fabricated defect handed to somebody at 3am, which is worse than finding nothing.
|
|
42
|
+
*/
|
|
43
|
+
export async function sessionPerturbationPort(realInput, appUrl) {
|
|
44
|
+
if (realInput === undefined || appUrl === undefined) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
const setMocks = realInput.setMocks?.bind(realInput);
|
|
48
|
+
if (setMocks === undefined) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
if (!(await realInput.isAvailableFor(appUrl))) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
slow: async (rules) => {
|
|
56
|
+
await setMocks(appUrl, [...rules]);
|
|
57
|
+
},
|
|
58
|
+
/** Clearing is the undo. A page left slowed is damage the next run inherits. */
|
|
59
|
+
clear: async () => {
|
|
60
|
+
await setMocks(appUrl, []);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Save/restore for this session's page, or nothing.
|
|
66
|
+
*
|
|
67
|
+
* The same job as `suiteFixtureSeed` next door, stopping one step earlier: that one captures and
|
|
68
|
+
* converts, this hands back the PORT so a caller can re-apply what it captured later. It lives here
|
|
69
|
+
* for the reason written above — a feature reaching into the input layer directly is a feature
|
|
70
|
+
* depending on a driver, and the boundary guard asks about it. It asked.
|
|
71
|
+
*/
|
|
72
|
+
export async function suiteFixturePort(realInput, appUrl) {
|
|
73
|
+
if (realInput === undefined || appUrl === undefined) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
return await fixturePortFor(realInput, appUrl);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* A way to break this session's page, or nothing.
|
|
85
|
+
*
|
|
86
|
+
* Sits beside `suiteFixtureSeed` because it is the same shape of job: turning a provider capability
|
|
87
|
+
* into something a feature can use, on the one side of the graph that is allowed to know about both.
|
|
88
|
+
* A flow tool reaching into the input layer directly would be a feature depending on a driver, which
|
|
89
|
+
* is the reach the boundary guard exists to ask about.
|
|
90
|
+
*/
|
|
91
|
+
export async function sessionMutationPort(realInput, appUrl) {
|
|
92
|
+
if (realInput === undefined || appUrl === undefined) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
return await mutationPortFor(realInput, appUrl);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// A provider that cannot answer is the same answer as a provider that cannot break anything.
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -94,6 +94,8 @@ export interface ToolDeps<Ext = unknown> {
|
|
|
94
94
|
url: string;
|
|
95
95
|
notice: string;
|
|
96
96
|
} | undefined>;
|
|
97
|
+
/** The one dev server running in the caller's project folder, for a lease that names no url. */
|
|
98
|
+
projectAppUrl?: () => Promise<string | undefined>;
|
|
97
99
|
/** cross-run outcome memory (.reticle/project.json). */
|
|
98
100
|
project: ProjectStore;
|
|
99
101
|
/** optional native-input provider. undefined ⇒ everything stays synthetic. */
|
|
@@ -170,13 +172,13 @@ export interface ToolDeps<Ext = unknown> {
|
|
|
170
172
|
export interface ToolDef<Ext = unknown> {
|
|
171
173
|
name: string;
|
|
172
174
|
description: string;
|
|
173
|
-
inputSchema: z.
|
|
175
|
+
inputSchema: Record<string, z.ZodType>;
|
|
174
176
|
/**
|
|
175
177
|
* JSON Schema-compatible output schema for this tool. When present, the MCP server advertises it
|
|
176
178
|
* in the tools/list response so schema-aware clients (like @reticlehq/cli) can validate outputs and
|
|
177
179
|
* compose tool calls safely. Also drives TOON encoding for snapshot/query results.
|
|
178
180
|
*/
|
|
179
|
-
outputSchema?: z.
|
|
181
|
+
outputSchema?: Record<string, z.ZodType>;
|
|
180
182
|
/**
|
|
181
183
|
* One concrete, valid call — the shape, not the prose.
|
|
182
184
|
*
|
|
@@ -369,7 +371,7 @@ export declare const SCREENSHOT_DOC = " `screenshot.path` is a picture of the pa
|
|
|
369
371
|
* Derived from `EnvelopeKey` rather than re-listed: this shape and the splice sites disagreed for a
|
|
370
372
|
* whole release, and every key that went missing was a channel nobody could see was missing.
|
|
371
373
|
*/
|
|
372
|
-
export declare const sessionEnvelopeShape: z.
|
|
374
|
+
export declare const sessionEnvelopeShape: Record<string, z.ZodType>;
|
|
373
375
|
/** Unwrap a browser command result or throw its error so the agent sees a clean failure. */
|
|
374
376
|
export declare function commandOrThrow(deps: ToolDeps, sessionId: string | undefined, name: ReticleCommand, args: Record<string, unknown>): Promise<unknown>;
|
|
375
377
|
/** Full DOM snapshot → normalized tree lines + route, for tools that diff or scan the page. */
|
|
@@ -324,7 +324,7 @@ export const RAW_TOOLS = withCompactEnvelope([
|
|
|
324
324
|
states: z.array(z.string()),
|
|
325
325
|
visible: z.boolean(),
|
|
326
326
|
attrs: z
|
|
327
|
-
.record(z.string())
|
|
327
|
+
.record(z.string(), z.string())
|
|
328
328
|
.optional()
|
|
329
329
|
.describe('Present only for attributes requested via `attrs` and found on the element.'),
|
|
330
330
|
source: z
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* It lives apart from `cli.ts` because it has its own timers and its own failure rule — nothing here
|
|
6
6
|
* may ever be able to fail a daemon start — and because `cli.ts` is a dispatcher.
|
|
7
7
|
*/
|
|
8
|
-
import { type SessionSummary } from '@reticlehq/core/telemetry';
|
|
8
|
+
import { DaemonSpawnMethod, type SessionSummary } from '@reticlehq/core/telemetry';
|
|
9
9
|
/**
|
|
10
10
|
* How often a running daemon rolls up its window.
|
|
11
11
|
*
|
|
@@ -29,6 +29,8 @@ export declare const SESSION_FLUSH_MS: number;
|
|
|
29
29
|
* costs nothing on a daemon that never serves a tool, because only a NON-EMPTY window emits.
|
|
30
30
|
*/
|
|
31
31
|
export declare const FIRST_FLUSH_MS: number;
|
|
32
|
+
/** How the parent started this daemon, from the env it set. Anything else reads as absent. */
|
|
33
|
+
export declare function daemonSpawnMethod(env?: Readonly<Record<string, string | undefined>>): DaemonSpawnMethod | undefined;
|
|
32
34
|
/** Stops the timers this installed. Called from the daemon's shutdown path. */
|
|
33
35
|
interface DaemonTelemetry {
|
|
34
36
|
/**
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* It lives apart from `cli.ts` because it has its own timers and its own failure rule — nothing here
|
|
6
6
|
* may ever be able to fail a daemon start — and because `cli.ts` is a dispatcher.
|
|
7
7
|
*/
|
|
8
|
-
import { TelemetryEventKind } from '@reticlehq/core/telemetry';
|
|
8
|
+
import { DaemonSpawnMethod, TelemetryEventKind, } from '@reticlehq/core/telemetry';
|
|
9
|
+
import { ReticleEnv } from '@reticlehq/core';
|
|
9
10
|
import { getTelemetry } from './telemetry.js';
|
|
10
11
|
import { getSessionMetrics } from './session-metrics.js';
|
|
11
12
|
import { profileProject } from './project-profile.js';
|
|
@@ -78,6 +79,11 @@ function withNudgeState(summary) {
|
|
|
78
79
|
...(nudge.offered === undefined ? {} : { updateOffered: nudge.offered }),
|
|
79
80
|
};
|
|
80
81
|
}
|
|
82
|
+
/** How the parent started this daemon, from the env it set. Anything else reads as absent. */
|
|
83
|
+
export function daemonSpawnMethod(env = process.env) {
|
|
84
|
+
const value = env[ReticleEnv.SPAWN_METHOD];
|
|
85
|
+
return Object.values(DaemonSpawnMethod).find((method) => method === value);
|
|
86
|
+
}
|
|
81
87
|
/**
|
|
82
88
|
* Report the daemon as started, profile the project once, and keep the session counters flushing.
|
|
83
89
|
* Every timer is `unref`'d so telemetry can never be the reason a daemon refuses to exit.
|
|
@@ -91,7 +97,8 @@ export function installDaemonTelemetry(cwd, now = () => Date.now(),
|
|
|
91
97
|
*/
|
|
92
98
|
port) {
|
|
93
99
|
const metrics = getSessionMetrics(now);
|
|
94
|
-
|
|
100
|
+
const spawnMethod = daemonSpawnMethod();
|
|
101
|
+
void getTelemetry().emit(TelemetryEventKind.DAEMON_STARTED, spawnMethod === undefined ? undefined : { spawnMethod });
|
|
95
102
|
// Reports a past send could not deliver. Off the boot path, and never able to throw into it.
|
|
96
103
|
void drainFeedbackOutbox().catch(() => undefined);
|
|
97
104
|
// Ask npm whether a newer Reticle exists, so the agent can tell the human. Nothing checked before
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { TelemetryEventKind, ProjectIdSource, type Crash, type Feedback, type Identity, type BugFound, type InitOutcome, type OnboardingStep, type InstallSource, type McpConnection, type McpOutage, type DaemonDeath, type ProxyExit, type WakeRefusal, type ToolRefusal, type AppInstrumentation, type HarnessRun, type HudEvent, type PlatformLink, type ProjectProfile, type SessionSummary, type TelemetryActor, type Verification, type VersionChange } from '@reticlehq/core/telemetry';
|
|
1
|
+
import { TelemetryEventKind, ProjectIdSource, type Crash, type Feedback, type Identity, type BugFound, type InitOutcome, type OnboardingStep, type InstallSource, type McpConnection, type McpOutage, type DaemonDeath, type ProxyExit, type WakeRefusal, type DaemonSpawnMethod, type ToolRefusal, type AppInstrumentation, type HarnessRun, type HudEvent, type PlatformLink, type ProjectProfile, type SessionSummary, type TelemetryActor, type Verification, type VersionChange } from '@reticlehq/core/telemetry';
|
|
2
2
|
/**
|
|
3
3
|
* Presence of this file marks the MACHINE as the Reticle team's own: every event carries
|
|
4
4
|
* `internal: true`. The persistent twin of `RETICLE_INTERNAL=1`, for a dev machine where setting a
|
|
@@ -113,6 +113,8 @@ export interface TelemetryExtra {
|
|
|
113
113
|
daemonDeath?: DaemonDeath;
|
|
114
114
|
/** `wake_refused`: what held the port. */
|
|
115
115
|
wakeRefusal?: WakeRefusal;
|
|
116
|
+
/** `daemon_started`: how the parent started the daemon. */
|
|
117
|
+
spawnMethod?: DaemonSpawnMethod;
|
|
116
118
|
/**
|
|
117
119
|
* `platform_linked`: the workspace just linked. Overrides the envelope's own reading, which looks
|
|
118
120
|
* at the daemon's working directory and not at the project the HUD linked.
|
|
@@ -470,6 +470,8 @@ export const createTelemetry = (opts) => {
|
|
|
470
470
|
...(extra?.proxyExit !== undefined ? { proxyExit: extra.proxyExit } : {}),
|
|
471
471
|
...(extra?.daemonDeath !== undefined ? { daemonDeath: extra.daemonDeath } : {}),
|
|
472
472
|
...(extra?.wakeRefusal !== undefined ? { wakeRefusal: extra.wakeRefusal } : {}),
|
|
473
|
+
// A SCALAR: the event build and the wire schema, no `blocks` entry.
|
|
474
|
+
...(extra?.spawnMethod !== undefined ? { spawnMethod: extra.spawnMethod } : {}),
|
|
473
475
|
};
|
|
474
476
|
// The wire schema's caps are only real if something applies them. An event that fails is
|
|
475
477
|
// DROPPED, never sent and never repaired: the payload is exactly what the caps exist to stop.
|
package/docs/agent-cheatsheet.md
CHANGED
|
@@ -48,6 +48,7 @@ Two consequences worth knowing:
|
|
|
48
48
|
|
|
49
49
|
- **You can assert what a field contains.** Do not read the value with `reticle_look { action: "find" }` and compare it in prose: that produces no verdict and does not count as verification.
|
|
50
50
|
- **`{ role, text }` checks the text,** so it does not match every button with that role.
|
|
51
|
+
- **A `text` predicate reads rendered text, not a field's value.** Text typed into an `<input>` or a `<textarea>` is not on the page as text, so `{ kind: "text", contains }` does not see it. Assert it with `value` on an `element` predicate, as above.
|
|
51
52
|
|
|
52
53
|
Fields that nothing can check are refused rather than ignored: `by` without a `value`, and `label`, `placeholder`, `testid` or `component` when a higher-precedence field already selected the element. `alt` is the one that can still be checked beside another locator: on an image it is compared with the image's `alt` attribute (so `alt: ""` passes on a decorative image but not on one missing its alt), and on anything that is not an image it is refused, with a hint to use `name`. An element query is a first-match dispatch, not a conjunction.
|
|
53
54
|
|
|
@@ -164,6 +165,17 @@ Recent additions, each of which answers a question agents were previously asking
|
|
|
164
165
|
2. `reticle_run({ tool: "reticle_domain", args: {} })` learns the app BEFORE testing: the saved flows, what each asserts, and the **gaps** (declared signals/testids that no flow verifies, i.e. untested intent). Tells you what to test and where the real risk is without crawling the whole app. Falls back to `reticle_run({ tool: "reticle_capabilities", args: {} })` for the raw testable surface (`testids`, `signals`, `stores`, `flows`).
|
|
165
166
|
3. Run the loop: **look → act → observe → assert**, cross-checking the 4 layers on anything that matters.
|
|
166
167
|
|
|
168
|
+
## No MCP tools in this host
|
|
169
|
+
|
|
170
|
+
A cloud agent or a worker can have a shell and no MCP tools. Use the CLI. `verify --expect` asks a running daemon and never starts one, so start it first:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
npx @reticlehq/server serve --headless
|
|
174
|
+
npx @reticlehq/server verify http://localhost:5173/ --expect '{"kind":"text","contains":"Welcome"}'
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
It exits `0` only on `verified: "yes"`. `--expect-file <path>` reads the predicate from a file, and `--timeout N` sets how long it waits (default `4000` ms). To move a connected tab to another page first, run `npx @reticlehq/server open <url> --navigate`. For an action that `--expect` cannot do, use the [HTTP MCP transport](/http-transport).
|
|
178
|
+
|
|
167
179
|
## Token note
|
|
168
180
|
|
|
169
181
|
- **Keep observation cheap.** Prefer `reticle_look { action: "find" }` / scoped or `interactive` `reticle_look { action: "page" }` / `reticle_assert` over dumping the full tree. A full verify loop is ~100 tokens; see [Token efficiency](/token-efficiency) (~73× leaner than full-tree snapshots).
|
package/docs/cli/gate.mdx
CHANGED
|
@@ -49,7 +49,7 @@ One `reticle_gate` line. Real capture from this repository, with the flow lists
|
|
|
49
49
|
}
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
`downgraded`, `deletedCoverage`, `unflowed`, `coverageRegressed`, `unexecuted`, `coverageStale` and `coverageAccepted` appear only when they are non-empty, and none was present in that run. The coverage categories need a coverage ledger, which a project gets from `reticle_verify { action: "crawl", exhaustive: true }` or `reticle_verify { action: "coverage", app: true }`; without one they never fire. The CLI prints this as a single line of JSON; it is pretty-printed here to be readable.
|
|
52
|
+
`downgraded`, `deletedCoverage`, `unflowed`, `coverageRegressed`, `unexecuted`, `coverageStale` and `coverageAccepted` appear only when they are non-empty, `flowsWithoutSources` only when it is `true`, and none was present in that run. The coverage categories need a coverage ledger, which a project gets from `reticle_verify { action: "crawl", exhaustive: true }` or `reticle_verify { action: "coverage", app: true }`; without one they never fire. The CLI prints this as a single line of JSON; it is pretty-printed here to be readable.
|
|
53
53
|
|
|
54
54
|
## Exit codes
|
|
55
55
|
|
package/docs/cli/verify.mdx
CHANGED
|
@@ -7,7 +7,7 @@ icon: circle-check
|
|
|
7
7
|
`reticle verify` is the CI command: no model in the loop, no MCP. On a free port it boots the engine in drive mode, waits for the in-page SDK to dial back, replays every saved flow, renders the verdict, and exits. When a Reticle daemon already owns the port, it asks that daemon to replay the flows instead.
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npx @reticlehq/server verify <url> [--headed] [--timeout N] [--storage-state <file>]
|
|
10
|
+
npx @reticlehq/server verify <url> [--headed] [--timeout N] [--storage-state <file>] [--expect <json> | --expect-file <path>]
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
It uses the same runner and the same verdict as the MCP and HTTP paths, so a platform agent that can only run a shell command gets a byte-identical artifact.
|
|
@@ -28,6 +28,13 @@ It uses the same runner and the same verdict as the MCP and HTTP paths, so a pla
|
|
|
28
28
|
| `--explore` / `--persona "<who does what>"` | boolean / string | off | No saved flows yet? The Harness drives the app and records them. `--persona` implies `--explore`. Needs a linked project (`reticle connect`, or `RETICLE_API_KEY` in CI). It runs at any instrumentation score |
|
|
29
29
|
| `--confirm-dangerous` | boolean | off | Let this run press the controls the replay guard holds as destructive, such as a "Place order" or "Delete" button in a saved flow. Without it such a step is refused, so a checkout flow cannot pass. It covers this run only and is never saved into a flow. |
|
|
30
30
|
|
|
31
|
+
On Windows PowerShell, write the predicate to a file and pass `--expect-file` so the JSON never goes through shell quoting:
|
|
32
|
+
|
|
33
|
+
```powershell
|
|
34
|
+
'{"kind":"text","contains":"Saved"}' | Set-Content expect.json
|
|
35
|
+
npx @reticlehq/server verify http://localhost:3000 --expect-file expect.json
|
|
36
|
+
```
|
|
37
|
+
|
|
31
38
|
## What it prints
|
|
32
39
|
|
|
33
40
|
The rendered run report on stdout. On failure it names the cause on stderr instead, and there are two refusals worth knowing:
|
package/docs/first-drive.md
CHANGED
|
@@ -72,6 +72,13 @@ node server/dist/command/cli.js verify http://localhost:4310/ --port 4400 \
|
|
|
72
72
|
--expect '{"kind":"text","contains":"definitely not on this page"}'
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
+
On Windows PowerShell, `npx.cmd` re-parses `--expect '{...}'` and strips the inner quotes. Prefer `--expect-file`:
|
|
76
|
+
|
|
77
|
+
```powershell
|
|
78
|
+
'{"kind":"text","contains":"Saved"}' | Set-Content expect.json
|
|
79
|
+
npx @reticlehq/server verify http://localhost:3000 --expect-file expect.json
|
|
80
|
+
```
|
|
81
|
+
|
|
75
82
|
## 3. Drive it properly, with the tools
|
|
76
83
|
|
|
77
84
|
If your editor has the Reticle MCP server, the same app is now drivable. The four calls worth knowing, in the order you actually use them:
|
|
@@ -36,6 +36,8 @@ reticle_run { tool: "reticle_lease", args: { action: "release", sessionId } }
|
|
|
36
36
|
|
|
37
37
|
`reticle_run { tool: "reticle_lease", args: { action: "acquire" } }` opens a fresh isolated headless context against your **already-running** app, stamps the lease identity into the URL so the app's own SDK registers under a sessionId you can target, and waits until that tab has connected (`ready: true`) before returning, so the sessionId is usable immediately. Release when the flow finishes.
|
|
38
38
|
|
|
39
|
+
**Lease the exact host that the dev server binds.** `http://localhost:3000` and `http://127.0.0.1:3000` are two origins, and they are not the same address. A dev server that binds only `::1` (Vite can do this for `localhost`) refuses a lease on `127.0.0.1`, and a server that binds only `127.0.0.1` does not answer on `::1`. The two origins also have their own cookies and storage, and a lease is reused for each origin. Copy the url that the dev server prints, host and port included.
|
|
40
|
+
|
|
39
41
|
## Permissions
|
|
40
42
|
|
|
41
43
|
A lease starts with every permission undecided. Pass `permissions` to grant some on the app's origin before the first navigation, so the first render already sees them:
|