@reticlehq/server 2.10.0 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +47 -9
- package/dist/dev/stale-issue-guard.d.ts +43 -0
- package/dist/dev/stale-issue-guard.js +75 -0
- package/dist/domain/domain-tools.js +3 -2
- package/dist/events/predicate-asks.d.ts +17 -0
- package/dist/events/predicate-asks.js +26 -0
- package/dist/events/predicate-eval.js +211 -24
- package/dist/events/predicate-parse.js +9 -3
- package/dist/events/predicate-schema.d.ts +18 -0
- package/dist/events/predicate-schema.js +55 -0
- package/dist/flows/flow-tools.js +33 -4
- package/dist/flows/predicate-to-expect.js +8 -0
- package/dist/flows/verify-change-tools.js +5 -1
- package/dist/honesty/blind-spots.d.ts +9 -0
- package/dist/honesty/blind-spots.js +17 -4
- package/dist/honesty/gap-ledger.d.ts +52 -0
- package/dist/honesty/gap-ledger.js +63 -0
- package/dist/honesty/instrumentation-gaps.d.ts +55 -0
- package/dist/honesty/instrumentation-gaps.js +21 -0
- package/dist/honesty/observability.d.ts +37 -0
- package/dist/honesty/observability.js +40 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.js +41 -0
- package/dist/init/node-io.js +3 -0
- package/dist/init/run.d.ts +18 -0
- package/dist/init/run.js +34 -3
- package/dist/init/slash-command.d.ts +1 -1
- package/dist/init/slash-command.js +33 -10
- package/dist/journal/envelope.d.ts +24 -24
- package/dist/license/license.d.ts +8 -2
- package/dist/license/license.js +10 -15
- package/dist/project/artifact-root.d.ts +83 -0
- package/dist/project/artifact-root.js +112 -0
- package/dist/project/memory-fs.d.ts +9 -0
- package/dist/project/memory-fs.js +88 -0
- package/dist/project/project-store.d.ts +17 -0
- package/dist/project/project-store.js +33 -0
- package/dist/project/remember-project.d.ts +24 -0
- package/dist/project/remember-project.js +40 -0
- package/dist/project/session-root.d.ts +15 -0
- package/dist/project/session-root.js +31 -0
- package/dist/runs/run-tools.js +2 -1
- package/dist/session/lease-availability.d.ts +40 -0
- package/dist/session/lease-availability.js +43 -0
- package/dist/session/no-session-diagnosis.d.ts +10 -0
- package/dist/session/no-session-diagnosis.js +19 -7
- package/dist/session/no-session-watch.js +1 -1
- package/dist/session/session.d.ts +9 -0
- package/dist/session/session.js +9 -0
- package/dist/session/stall-clock.d.ts +18 -0
- package/dist/session/stall-clock.js +48 -0
- package/dist/telemetry/daemon-telemetry.js +12 -23
- package/dist/telemetry/session-metrics.d.ts +1 -1
- package/dist/telemetry/session-metrics.js +1 -1
- package/dist/telemetry/telemetry.d.ts +12 -3
- package/dist/telemetry/telemetry.js +25 -5
- package/dist/tools/act-tools.js +34 -4
- package/dist/tools/assert-verdict.d.ts +2 -0
- package/dist/tools/assert-verdict.js +24 -1
- package/dist/tools/contract-tools.js +5 -3
- package/dist/tools/coverage-tools.js +33 -0
- package/dist/tools/observe-tools.js +8 -2
- package/dist/tools/tool-kit.d.ts +16 -1
- package/docs/README.md +1 -0
- package/docs/docs.json +2 -2
- package/docs/enterprise.md +3 -1
- package/docs/license-activation.mdx +145 -0
- package/docs/telemetry-contract.md +1 -12
- package/docs/telemetry-events.mdx +1 -34
- package/docs/telemetry.md +0 -1
- package/package.json +2 -2
- package/dist/telemetry/instrumentation-stall.d.ts +0 -57
- package/dist/telemetry/instrumentation-stall.js +0 -103
|
@@ -13,8 +13,7 @@ import { profileProject } from './project-profile.js';
|
|
|
13
13
|
import { startUpdateCheck } from '../update/update-nudge.js';
|
|
14
14
|
import { markDaemonStart } from './mcp-connection.js';
|
|
15
15
|
import { markInstrumentationClock } from './app-instrumented.js';
|
|
16
|
-
import { markStallClock,
|
|
17
|
-
import { readProjectId } from '../cli/cli-port.js';
|
|
16
|
+
import { markStallClock, STALL_AFTER_MS, stallUptime } from '../session/stall-clock.js';
|
|
18
17
|
/** Let the daemon finish coming up before walking the source tree for a profile. */
|
|
19
18
|
const PROJECT_PROFILE_DELAY_MS = 5_000;
|
|
20
19
|
/**
|
|
@@ -74,19 +73,18 @@ export function installDaemonTelemetry(cwd, now = () => Date.now()) {
|
|
|
74
73
|
/* a profile is a nice-to-have; it must never touch the daemon */
|
|
75
74
|
}
|
|
76
75
|
}, PROJECT_PROFILE_DELAY_MS).unref();
|
|
76
|
+
// Once per daemon run: a warning repeated every ten minutes at somebody already stuck is noise.
|
|
77
|
+
let stallWarned = false;
|
|
77
78
|
const flushWindow = () => {
|
|
78
|
-
// BEFORE the idle return, and that ordering
|
|
79
|
-
// empty window by definition — no app, so no tool
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
agentAttached: metrics.agentEverAttached,
|
|
88
|
-
}, now);
|
|
89
|
-
if (stalled) {
|
|
79
|
+
// BEFORE the idle return, and that ordering still matters for the reason the removed event
|
|
80
|
+
// documented: a stalled install produces an empty window by definition — no app, so no tool
|
|
81
|
+
// calls, so nothing to roll up — so checking after the early return would never fire on the one
|
|
82
|
+
// case worth warning about. What is left here is the WARNING, for a human watching the log; the
|
|
83
|
+
// metric that used to ride alongside it was removed as a second, less complete way to compute
|
|
84
|
+
// `daemon_started` minus `app_instrumented`.
|
|
85
|
+
const waited = stallUptime(now());
|
|
86
|
+
if (!stallWarned && waited !== undefined && waited >= STALL_AFTER_MS) {
|
|
87
|
+
stallWarned = true;
|
|
90
88
|
const minutes = Math.round(STALL_AFTER_MS / 60_000);
|
|
91
89
|
process.stderr.write(`[reticle] no app has connected after ${String(minutes)} minutes — ` +
|
|
92
90
|
'the install may be incomplete. Restart the dev server if you added the Reticle plugin.\n');
|
|
@@ -113,15 +111,6 @@ export function installDaemonTelemetry(cwd, now = () => Date.now()) {
|
|
|
113
111
|
stopped ??= (async () => {
|
|
114
112
|
clearInterval(flush);
|
|
115
113
|
clearTimeout(firstFlush);
|
|
116
|
-
// Last chance to say that no app ever arrived. The periodic path cannot report an
|
|
117
|
-
// UNATTENDED stall at all — that daemon idle-exits at five minutes and the periodic
|
|
118
|
-
// threshold is ten — so without this the event only ever describes daemons with an agent
|
|
119
|
-
// attached, which is not the population it was built to measure. Self-limiting: if the
|
|
120
|
-
// interval already reported, this returns false and emits nothing.
|
|
121
|
-
reportInstrumentationStall({
|
|
122
|
-
initialized: readProjectId(cwd) !== undefined,
|
|
123
|
-
agentAttached: metrics.agentEverAttached,
|
|
124
|
-
}, now, { atShutdown: true });
|
|
125
114
|
// The rich one: the whole session in a single event — duration, the tool histogram, error
|
|
126
115
|
// shapes, verifications, browser launches. This is what replaced the per-tool-call event.
|
|
127
116
|
await getTelemetry().emit(TelemetryEventKind.DAEMON_STOPPED, {
|
|
@@ -133,7 +133,7 @@ export declare class SessionMetrics {
|
|
|
133
133
|
/**
|
|
134
134
|
* Has an agent ever attached to this daemon?
|
|
135
135
|
*
|
|
136
|
-
* Read by the
|
|
136
|
+
* Read by the stall clock, which needs to tell two very different silences apart:
|
|
137
137
|
* a daemon nobody is using, and a daemon with an agent sitting on it that has no app to drive.
|
|
138
138
|
* The second is the funnel's failure case and the first is not a problem at all.
|
|
139
139
|
*/
|
|
@@ -575,7 +575,7 @@ export class SessionMetrics {
|
|
|
575
575
|
/**
|
|
576
576
|
* Has an agent ever attached to this daemon?
|
|
577
577
|
*
|
|
578
|
-
* Read by the
|
|
578
|
+
* Read by the stall clock, which needs to tell two very different silences apart:
|
|
579
579
|
* a daemon nobody is using, and a daemon with an agent sitting on it that has no app to drive.
|
|
580
580
|
* The second is the funnel's failure case and the first is not a problem at all.
|
|
581
581
|
*/
|
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
import { TelemetryEventKind, ProjectIdSource, type Crash, type Feedback, type Identity, type BugFound, type InitOutcome, type InstallSource, type McpConnection, type McpOutage, type ToolRefusal, type AppInstrumentation, type
|
|
1
|
+
import { TelemetryEventKind, ProjectIdSource, type Crash, type Feedback, type Identity, type BugFound, type InitOutcome, type InstallSource, type McpConnection, type McpOutage, type ToolRefusal, type AppInstrumentation, type ProjectProfile, type SessionSummary, type TelemetryActor, type Verification, type VersionChange } from '@reticlehq/core';
|
|
2
|
+
/**
|
|
3
|
+
* PostHog's group type for a licensed customer. Sent as `$groups` so PostHog can aggregate a whole
|
|
4
|
+
* organisation natively: per-company retention, "which orgs went quiet", one row per customer rather
|
|
5
|
+
* than one per machine.
|
|
6
|
+
*
|
|
7
|
+
* The KEY is the licence id and nothing else. The organisation's NAME is never sent from a customer's
|
|
8
|
+
* machine, here or anywhere; the name reaches PostHog only if we push it ourselves from the issuance
|
|
9
|
+
* ledger, which is the one place that mapping lives. So an event still carries no identity, and the
|
|
10
|
+
* analytics side learns a customer list only when we deliberately upload one.
|
|
11
|
+
*/
|
|
12
|
+
export declare const POSTHOG_GROUP_ORGANIZATION = "organization";
|
|
2
13
|
/**
|
|
3
14
|
* Feedback gets its own, much larger budget — and a retry.
|
|
4
15
|
*
|
|
@@ -78,8 +89,6 @@ export interface TelemetryExtra {
|
|
|
78
89
|
outage?: McpOutage;
|
|
79
90
|
/** `app_instrumented`: an app carrying the SDK reached this daemon for the first time. */
|
|
80
91
|
instrumentation?: AppInstrumentation;
|
|
81
|
-
/** `instrumentation_stalled`: the daemon waited and no app ever arrived. */
|
|
82
|
-
stall?: InstrumentationStall;
|
|
83
92
|
/** `reticle_installed` / `init_completed`: which published route brought this install in. */
|
|
84
93
|
installSource?: InstallSource;
|
|
85
94
|
}
|
|
@@ -42,6 +42,17 @@ const POSTHOG_KEY = 'phc_q4yCxKMXHfWQ43VAz4HxJXZFLqFi4nBbg5v3avemggYo';
|
|
|
42
42
|
* still key on distinct_id.
|
|
43
43
|
*/
|
|
44
44
|
const POSTHOG_PERSONLESS = { $process_person_profile: false };
|
|
45
|
+
/**
|
|
46
|
+
* PostHog's group type for a licensed customer. Sent as `$groups` so PostHog can aggregate a whole
|
|
47
|
+
* organisation natively: per-company retention, "which orgs went quiet", one row per customer rather
|
|
48
|
+
* than one per machine.
|
|
49
|
+
*
|
|
50
|
+
* The KEY is the licence id and nothing else. The organisation's NAME is never sent from a customer's
|
|
51
|
+
* machine, here or anywhere; the name reaches PostHog only if we push it ourselves from the issuance
|
|
52
|
+
* ledger, which is the one place that mapping lives. So an event still carries no identity, and the
|
|
53
|
+
* analytics side learns a customer list only when we deliberately upload one.
|
|
54
|
+
*/
|
|
55
|
+
export const POSTHOG_GROUP_ORGANIZATION = 'organization';
|
|
45
56
|
const SEND_TIMEOUT_MS = 2000;
|
|
46
57
|
/**
|
|
47
58
|
* Feedback gets its own, much larger budget — and a retry.
|
|
@@ -337,7 +348,6 @@ export const createTelemetry = (opts) => {
|
|
|
337
348
|
...(extra?.refusal !== undefined ? { refusal: extra.refusal } : {}),
|
|
338
349
|
...(extra?.outage !== undefined ? { outage: extra.outage } : {}),
|
|
339
350
|
...(extra?.instrumentation !== undefined ? { instrumentation: extra.instrumentation } : {}),
|
|
340
|
-
...(extra?.stall !== undefined ? { stall: extra.stall } : {}),
|
|
341
351
|
// A SCALAR, so it needs the event build and the wire schema but no entry in `blocks` below —
|
|
342
352
|
// there is nothing to flatten. It still needs both of those, which is the trap `outage` fell
|
|
343
353
|
// into: a field declared on TelemetryExtra and missing from either one is dropped in silence.
|
|
@@ -348,14 +358,17 @@ export const createTelemetry = (opts) => {
|
|
|
348
358
|
// `blocks` entry. Resolved per event off the event's own clock rather than once at startup — an
|
|
349
359
|
// eleven-hour session must not report the status it booted with. All three are absent unless a
|
|
350
360
|
// release has an issuer key baked, so an OSS payload is byte-identical to before.
|
|
351
|
-
|
|
361
|
+
// The RESOLVED env, not process.env: createTelemetry takes an env for injection, and reading
|
|
362
|
+
// the ambient one here would silently ignore it. Identical in production, wrong everywhere the
|
|
363
|
+
// seam is actually used.
|
|
364
|
+
...licenseFacts(eventTs, env),
|
|
352
365
|
};
|
|
353
366
|
// Map the core contract onto PostHog's capture shape: id/name/time move up, the rest are properties.
|
|
354
367
|
// The feedback body is FLATTENED into `feedback_*` properties rather than sent as a nested object:
|
|
355
368
|
// PostHog filters and breakdowns operate on top-level properties, so a nested `feedback.stack`
|
|
356
369
|
// would be invisible to the exact "which stacks report the most bugs" question this exists to
|
|
357
370
|
// answer. One prefix keeps them grouped in the UI without a nested path.
|
|
358
|
-
const { anonymousId: distinctId, event: name, ts, sessionId: eventSessionId, feedback, session, project, verification, versionChange, crash, identity, connection, init, bug, refusal, outage, instrumentation,
|
|
371
|
+
const { anonymousId: distinctId, event: name, ts, sessionId: eventSessionId, feedback, session, project, verification, versionChange, crash, identity, connection, init, bug, refusal, outage, instrumentation, ...rest } = event;
|
|
359
372
|
// `$session_id` is PostHog's OWN session property, so sending ours under that name lights up
|
|
360
373
|
// its built-in session views and funnels for free rather than requiring custom HogQL everywhere.
|
|
361
374
|
// `sessionId` rides only on events that happened inside a daemon run. A one-shot CLI command
|
|
@@ -386,7 +399,6 @@ export const createTelemetry = (opts) => {
|
|
|
386
399
|
refusal,
|
|
387
400
|
outage,
|
|
388
401
|
instrumentation,
|
|
389
|
-
stall,
|
|
390
402
|
};
|
|
391
403
|
for (const [prefix, block] of Object.entries(blocks)) {
|
|
392
404
|
for (const [key, value] of Object.entries(block ?? {})) {
|
|
@@ -397,7 +409,15 @@ export const createTelemetry = (opts) => {
|
|
|
397
409
|
event: name,
|
|
398
410
|
distinct_id: distinctId,
|
|
399
411
|
timestamp: new Date(ts).toISOString(),
|
|
400
|
-
properties: {
|
|
412
|
+
properties: {
|
|
413
|
+
...properties,
|
|
414
|
+
...POSTHOG_PERSONLESS,
|
|
415
|
+
// Only on a licensed build. An unlicensed event has no organisation to belong to, and an
|
|
416
|
+
// empty group key would mint a phantom "no org" bucket that every OSS install falls into.
|
|
417
|
+
...(event.licenseId !== undefined
|
|
418
|
+
? { $groups: { [POSTHOG_GROUP_ORGANIZATION]: event.licenseId } }
|
|
419
|
+
: {}),
|
|
420
|
+
},
|
|
401
421
|
};
|
|
402
422
|
const body = JSON.stringify({ api_key: apiKey, batch: [capture] });
|
|
403
423
|
const filePath = env[Env.FILE];
|
package/dist/tools/act-tools.js
CHANGED
|
@@ -8,7 +8,7 @@ import { resolveTargetRef } from './resolve-target.js';
|
|
|
8
8
|
import { z } from 'zod';
|
|
9
9
|
import { aliasParam } from './alias-args.js';
|
|
10
10
|
import { captureAct, compileSequenceStep } from '../flows/replay.js';
|
|
11
|
-
import { ActionType, ActionWarning, CaptureLoss, DEFAULT_ASSERT_TIMEOUT_MS, InputMode, ReticleCommand, Verified, PredicateKind, } from '@reticlehq/core';
|
|
11
|
+
import { ActionType, ActionWarning, CaptureLoss, DEFAULT_ASSERT_TIMEOUT_MS, InputMode, ReticleCommand, Verified, VerifiedReason, PredicateKind, } from '@reticlehq/core';
|
|
12
12
|
import { assertNativeInputSupported } from './act-danger.js';
|
|
13
13
|
import { leanActResult, mutatedWithin } from './act-view.js';
|
|
14
14
|
import { ReticleTool } from './tool-names.js';
|
|
@@ -16,6 +16,10 @@ import { buildReactionReport, summarizeReaction } from '../events/reaction.js';
|
|
|
16
16
|
import { parsePredicate } from '../events/predicate-parse.js';
|
|
17
17
|
import { causalSummary } from '../capsule/causal-summary.js';
|
|
18
18
|
import { findContradictions } from '../events/contradictions.js';
|
|
19
|
+
import { gapsForAction } from '../honesty/instrumentation-gaps.js';
|
|
20
|
+
import { noteSessionGaps } from '../honesty/gap-ledger.js';
|
|
21
|
+
import { declaresState } from '../events/predicate-asks.js';
|
|
22
|
+
import { isStateUnwatched } from '../honesty/blind-spots.js';
|
|
19
23
|
import { inFlightRequestLabels, repeatedRequestLabels, waitForInFlight, } from './settle-in-flight.js';
|
|
20
24
|
import { waitForReaction } from './react-grace.js';
|
|
21
25
|
import { decideVerified } from '../honesty/verified.js';
|
|
@@ -26,7 +30,7 @@ import { saveFailedAssertCapsule } from './act-capsule.js';
|
|
|
26
30
|
import { buildDivergenceCapsule } from '../capsule/capsule.js';
|
|
27
31
|
import { predicateToExpectedLinks } from '../capsule/predicate-to-links.js';
|
|
28
32
|
import { buildHonestyBlock } from '../honesty/honesty.js';
|
|
29
|
-
import {
|
|
33
|
+
import { buildCoverageStatement, blindSpotsFromState, transportGapNote, Coverage, impeachesCapture, } from '../honesty/blind-spots.js';
|
|
30
34
|
import { hasAcceptedWrite } from '../honesty/accepted-write.js';
|
|
31
35
|
import { unreadWriteLabels } from '../honesty/unread-outcome.js';
|
|
32
36
|
import { evaluatePredicate, waitForPredicate, provenExpectedLinks, PredicateSchema, } from '../events/predicate.js';
|
|
@@ -432,6 +436,10 @@ export const ACT_TOOLS = [
|
|
|
432
436
|
.array(z.unknown())
|
|
433
437
|
.optional()
|
|
434
438
|
.describe('Channels that DISAGREE about this action — the UI advanced while its write failed, a success signal fired over a failed request, a response changed nothing, a duplicate fired, a request never settled. OMITTED when clean. Treat any entry as a finding even when the verdict is green: a passing assertion and a contradicted channel is exactly the false green this exists to catch.'),
|
|
439
|
+
instrumentationGaps: z
|
|
440
|
+
.array(z.unknown())
|
|
441
|
+
.optional()
|
|
442
|
+
.describe('What the APP did not tell Reticle, and the one change that would fix it — each entry is { kind, missing, cost, fix, source?, ref? }. Reported ONLY where an absence made THIS verdict weaker (a red that cannot name a file:line, a state assertion with no registered store, a DOM change no signal announced, a route change nothing signalled); never a survey of the page, so an entry is always work worth doing now. OMITTED when the app told Reticle everything it needed. Applying `fix` makes every later verdict on this app stronger, not just this one.'),
|
|
435
443
|
honesty: z
|
|
436
444
|
.unknown()
|
|
437
445
|
.describe('The verdict trust block { grade, attribution, coverage, integrity, envelope? } — a green never looks stronger than this. Gate on grade ≥ net AND integrity.clean. Fields that were not measured are OMITTED rather than reported as zero, so treat an absent `envelope` as "not sampled", never as a failure.'),
|
|
@@ -561,7 +569,7 @@ export const ACT_TOOLS = [
|
|
|
561
569
|
const coverage = buildCoverageStatement(spots);
|
|
562
570
|
// Nothing subscribed ⇒ the state channel is dark, and the summary must say so rather than
|
|
563
571
|
// report an empty diff list that reads like a fact about the app. See CausalSummary.
|
|
564
|
-
const stateUnwatched = spots
|
|
572
|
+
const stateUnwatched = isStateUnwatched(spots);
|
|
565
573
|
// Only a spot that IMPEACHES the capture belongs in integrity — see impeachesCapture. A
|
|
566
574
|
// structural boundary (virtualized rows, a cross-origin frame) is reported as coverage and
|
|
567
575
|
// must not downgrade a verdict about what WAS observed.
|
|
@@ -672,6 +680,25 @@ export const ACT_TOOLS = [
|
|
|
672
680
|
repeated: repeatedRequestLabels(windowEvents),
|
|
673
681
|
},
|
|
674
682
|
});
|
|
683
|
+
// Computed once: the verdict block reports it, and the instrumentation gaps are a second
|
|
684
|
+
// reading of the same evidence rather than a new observation.
|
|
685
|
+
const actionSummary = causalSummary(windowEvents, { stateUnwatched });
|
|
686
|
+
const gaps = gapsForAction({
|
|
687
|
+
pass: verdict.pass,
|
|
688
|
+
proved: decision.verifiedReason === VerifiedReason.PROVED,
|
|
689
|
+
sourceKnown: actedSource !== undefined,
|
|
690
|
+
ref: asString(args['ref']),
|
|
691
|
+
stateAsked: declaresState(until),
|
|
692
|
+
stateUnwatched,
|
|
693
|
+
domMutated: (session.lastAct.effect().mutatedWithin ?? 0) > 0,
|
|
694
|
+
signalsFired: actionSummary.signals.length,
|
|
695
|
+
routeChanged: actionSummary.route !== undefined,
|
|
696
|
+
routeSignalFired: actionSummary.signals.some((name) => name.startsWith('route')),
|
|
697
|
+
});
|
|
698
|
+
// Recorded on the session, so a later "am I done?" can answer with what is STILL missing
|
|
699
|
+
// rather than with everything that was ever missing. An empty list closes a gap, which is
|
|
700
|
+
// why it is noted rather than skipped.
|
|
701
|
+
noteSessionGaps(session, gaps);
|
|
675
702
|
return withControl(session, {
|
|
676
703
|
...decision,
|
|
677
704
|
effect: leanActResult(actResult.result),
|
|
@@ -686,7 +713,10 @@ export const ACT_TOOLS = [
|
|
|
686
713
|
...(capsuleSaved === undefined ? {} : { capsuleSaved }),
|
|
687
714
|
// The window cannot say whether anything was WATCHING state — that is a level fact the
|
|
688
715
|
// session holds. Without it an empty `stateDiffs` reads as "the app changed nothing".
|
|
689
|
-
summary:
|
|
716
|
+
summary: actionSummary,
|
|
717
|
+
// What the APP did not tell Reticle, and the one change that would fix it. Reported only
|
|
718
|
+
// where an absence made this very verdict weaker — never as a survey of the page.
|
|
719
|
+
...(gaps.length > 0 ? { instrumentationGaps: gaps } : {}),
|
|
690
720
|
// Cross-channel disagreement, reported WITH the action that caused it.
|
|
691
721
|
//
|
|
692
722
|
// This is the one finding here a human structurally cannot make — they watch one channel,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { InstrumentationGap } from '@reticlehq/core';
|
|
1
2
|
import type { Predicate } from '../events/predicate.js';
|
|
2
3
|
import type { Session } from '../session/session.js';
|
|
3
4
|
import { type Contradiction } from '../events/contradictions.js';
|
|
@@ -26,4 +27,5 @@ observationLost?: boolean): Promise<{
|
|
|
26
27
|
decision: Record<string, unknown>;
|
|
27
28
|
contradictions: Contradiction[];
|
|
28
29
|
coverage: Record<string, unknown>;
|
|
30
|
+
gaps: InstrumentationGap[];
|
|
29
31
|
}>;
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { BlindSpotKind, CaptureLoss, PredicateKind } from '@reticlehq/core';
|
|
2
|
+
import { gapsForAction } from '../honesty/instrumentation-gaps.js';
|
|
3
|
+
import { noteSessionGaps } from '../honesty/gap-ledger.js';
|
|
4
|
+
import { declaresState } from '../events/predicate-asks.js';
|
|
5
|
+
import { isStateUnwatched } from '../honesty/blind-spots.js';
|
|
2
6
|
import { findContradictions } from '../events/contradictions.js';
|
|
3
7
|
import { declaredExpectations } from '../events/declared.js';
|
|
4
8
|
import { blindSpotsFromState, buildCoverageStatement, Coverage, impeachesCapture, transportGapNote, } from '../honesty/blind-spots.js';
|
|
@@ -122,5 +126,24 @@ observationLost) {
|
|
|
122
126
|
repeated: repeatedRequestLabels(windowEvents),
|
|
123
127
|
},
|
|
124
128
|
});
|
|
125
|
-
|
|
129
|
+
// The same rule the act path uses, fed by what THIS path knows. An assertion drives nothing, so
|
|
130
|
+
// only two of the four gates can fire here: a red with no remembered source to point at, and a
|
|
131
|
+
// state assertion against an app that registers no store.
|
|
132
|
+
const gaps = gapsForAction({
|
|
133
|
+
pass,
|
|
134
|
+
sourceKnown: session.lastAct.source() !== undefined,
|
|
135
|
+
stateAsked: declaresState(predicate),
|
|
136
|
+
stateUnwatched: isStateUnwatched(spots),
|
|
137
|
+
domMutated: false,
|
|
138
|
+
signalsFired: 0,
|
|
139
|
+
routeChanged: false,
|
|
140
|
+
routeSignalFired: false,
|
|
141
|
+
});
|
|
142
|
+
noteSessionGaps(session, gaps);
|
|
143
|
+
return {
|
|
144
|
+
decision: decision,
|
|
145
|
+
contradictions,
|
|
146
|
+
coverage,
|
|
147
|
+
gaps,
|
|
148
|
+
};
|
|
126
149
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { sessionRoot } from '../project/session-root.js';
|
|
2
3
|
import { CapabilitiesSchema, ContractReadError, FROM_DISK_ARG, ReticleCommand, } from '@reticlehq/core';
|
|
3
4
|
import { ReticleTool } from './tool-names.js';
|
|
4
5
|
import { asString } from './tools-helpers.js';
|
|
@@ -59,7 +60,7 @@ export const CONTRACT_TOOLS = [
|
|
|
59
60
|
},
|
|
60
61
|
handler: async (deps, args) => {
|
|
61
62
|
if (true === args[FROM_DISK_ARG]) {
|
|
62
|
-
const r = await readContract(deps.fs, deps
|
|
63
|
+
const r = await readContract(deps.fs, sessionRoot(deps, asString(args['sessionId'])));
|
|
63
64
|
if (!r.ok)
|
|
64
65
|
throw new Error(r.reason === ContractReadError.MISSING
|
|
65
66
|
? 'no .reticle/contract.json on disk — run reticle_contract_save first (or omit fromDisk to read the live session)'
|
|
@@ -84,10 +85,11 @@ export const CONTRACT_TOOLS = [
|
|
|
84
85
|
const res = await commandOrThrow(deps, asString(args['sessionId']), ReticleCommand.CAPABILITIES, {});
|
|
85
86
|
const caps = CapabilitiesSchema.parse(res);
|
|
86
87
|
assertSameProject(deps, asString(args['sessionId']));
|
|
87
|
-
|
|
88
|
+
const root = sessionRoot(deps, asString(args['sessionId']));
|
|
89
|
+
await writeContract(deps.fs, root, caps, deps.now);
|
|
88
90
|
return {
|
|
89
91
|
saved: true,
|
|
90
|
-
path: reticleDirPaths(
|
|
92
|
+
path: reticleDirPaths(root).contract,
|
|
91
93
|
testidCount: caps.testids.length,
|
|
92
94
|
signalCount: caps.signals.length,
|
|
93
95
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { coverageRegressed, observabilityOf } from '../honesty/observability.js';
|
|
2
3
|
import { ReticleCommand, SnapshotMode } from '@reticlehq/core';
|
|
3
4
|
import { ReticleTool } from './tool-names.js';
|
|
4
5
|
import { asString } from './tools-helpers.js';
|
|
@@ -62,6 +63,22 @@ export const COVERAGE_TOOLS = [
|
|
|
62
63
|
.number()
|
|
63
64
|
.optional()
|
|
64
65
|
.describe('Controls you drove that are no longer on the page — usually because the action SUCCEEDED and removed them (archive/delete/submit/navigate). Counted separately so `exercised: 0` never appears immediately after real work.'),
|
|
66
|
+
instrumentationGaps: z
|
|
67
|
+
.array(z.unknown())
|
|
68
|
+
.optional()
|
|
69
|
+
.describe('What this app still cannot tell Reticle, as of your most recent verdict — each entry is { kind, missing, cost, fix, source?, ref? }. These are not controls you skipped; they are checks this app CANNOT answer until it is instrumented, so driving the untouched list will not close them. Apply each `fix` and re-verify: the gap disappears from this list when the app can answer, and every later verdict on this app gets stronger. OMITTED when nothing is missing.'),
|
|
70
|
+
unproven: z
|
|
71
|
+
.boolean()
|
|
72
|
+
.optional()
|
|
73
|
+
.describe('True when verification is NOT finished for a reason driving more controls cannot fix — instrumentationGaps is non-empty. Present only when true, so its absence is not a claim.'),
|
|
74
|
+
observability: z
|
|
75
|
+
.object({ driven: z.number(), observable: z.number(), percent: z.number().optional() })
|
|
76
|
+
.optional()
|
|
77
|
+
.describe('Of the controls you DROVE, how many Reticle could fully observe. `untouched` above is work left for you; this is work left in the APP, and driving more controls does not move it. `percent` is OMITTED when nothing was driven, because 0/0 is not 100%.'),
|
|
78
|
+
observabilityRegressed: z
|
|
79
|
+
.object({ was: z.number(), now: z.number() })
|
|
80
|
+
.optional()
|
|
81
|
+
.describe('This project has previously reached a HIGHER observability than this run did. Usually means an assertion or an instrumented path was removed — the cheapest way to stop a gap firing is to stop asserting the thing that revealed it. Present only when a drop is real and the run was large enough to compare.'),
|
|
65
82
|
},
|
|
66
83
|
handler: async (deps, args) => {
|
|
67
84
|
const sessionId = asString(args['sessionId']);
|
|
@@ -77,11 +94,27 @@ export const COVERAGE_TOOLS = [
|
|
|
77
94
|
// `droveGone` keeps the other honesty: archive/delete/submit remove their own control, so a
|
|
78
95
|
// drive that WORKED must not read as no coverage at all.
|
|
79
96
|
const { exercised, droveGone, untouched } = exercisedCount(parseControls(tree), session.actedRefs(), session.actedLabels());
|
|
97
|
+
// The other half of "am I done?". `untouched` answers what you did not DRIVE; this answers
|
|
98
|
+
// what this app cannot ANSWER, which no amount of further driving will change. Reporting the
|
|
99
|
+
// first without the second is how an agent finishes a pass believing it verified something the
|
|
100
|
+
// app was never able to confirm.
|
|
101
|
+
const gaps = session.gaps?.open() ?? [];
|
|
102
|
+
// The number, and the floor under it, together. A coverage figure that can only ever be
|
|
103
|
+
// reported and never contradicted is one an agent learns to satisfy rather than to earn.
|
|
104
|
+
const observability = observabilityOf(session.actedRefs(), gaps);
|
|
105
|
+
const best = await deps.project.bestObservability();
|
|
106
|
+
const regressed = coverageRegressed(best, observability);
|
|
107
|
+
if (observability.percent !== undefined) {
|
|
108
|
+
await deps.project.raiseObservability(observability.percent);
|
|
109
|
+
}
|
|
80
110
|
return {
|
|
81
111
|
total: parseControls(tree).length,
|
|
82
112
|
exercised,
|
|
83
113
|
untouched,
|
|
84
114
|
...(droveGone > 0 ? { alsoDroveGone: droveGone } : {}),
|
|
115
|
+
...(gaps.length > 0 ? { instrumentationGaps: gaps, unproven: true } : {}),
|
|
116
|
+
observability,
|
|
117
|
+
...(regressed === undefined ? {} : { observabilityRegressed: regressed }),
|
|
85
118
|
};
|
|
86
119
|
},
|
|
87
120
|
},
|
|
@@ -264,7 +264,7 @@ export const OBSERVE_TOOLS = [
|
|
|
264
264
|
// that guessed `urlContains` on route (net's spelling) and got unrecognized_keys.
|
|
265
265
|
'Predicate to evaluate. Kinds: { signal, name } { net, urlContains|method|status|count|bodyContains } ' +
|
|
266
266
|
'{ state, path|equals } { route, pathname (exact) | contains (path+query+hash) } ' +
|
|
267
|
-
'{ element, testid|role|text } { text } { console, level|absent } { animation, name } ' +
|
|
267
|
+
'{ element, testid|role|text } { text } { console, level|contains|absent } { animation, name } ' +
|
|
268
268
|
'{ settled } — combine with { allOf | anyOf | not }. Prefer a signal/net/state consequence ' +
|
|
269
269
|
'over element/text presence.'),
|
|
270
270
|
until: PredicateSchema.optional().describe("Alias for `predicate` (act_and_wait's name)."),
|
|
@@ -318,6 +318,10 @@ export const OBSERVE_TOOLS = [
|
|
|
318
318
|
.array(z.object({ kind: z.string(), count: z.number() }))
|
|
319
319
|
.optional()
|
|
320
320
|
.describe('Which regions were unobservable, when coverage is partial.'),
|
|
321
|
+
instrumentationGaps: z
|
|
322
|
+
.array(z.unknown())
|
|
323
|
+
.optional()
|
|
324
|
+
.describe('What the APP did not tell Reticle, and the one change that would fix it — each entry is { kind, missing, cost, fix, source?, ref? }. Reported ONLY where an absence made THIS verdict weaker: a failing assertion with no source to point at, or a state assertion against an app that registers no store. Never a survey of the page, so an entry is always work worth doing now. OMITTED when the app told Reticle everything it needed.'),
|
|
321
325
|
},
|
|
322
326
|
handler: async (deps, args) => {
|
|
323
327
|
const session = deps.sessions.resolve(asString(args['sessionId']));
|
|
@@ -341,11 +345,13 @@ export const OBSERVE_TOOLS = [
|
|
|
341
345
|
: assertsDerivedIpcStatus(predicate)
|
|
342
346
|
? { advice: DERIVED_IPC_STATUS_ADVICE }
|
|
343
347
|
: {};
|
|
344
|
-
const { decision, contradictions, coverage } = await assertVerdict(session, predicate, verdict.pass, since, verdict.inconclusive, verdict.observationLost);
|
|
348
|
+
const { decision, contradictions, coverage, gaps } = await assertVerdict(session, predicate, verdict.pass, since, verdict.inconclusive, verdict.observationLost);
|
|
345
349
|
return withControl(session, {
|
|
346
350
|
...decision,
|
|
347
351
|
...verdict,
|
|
348
352
|
...(contradictions.length > 0 ? { contradictions } : {}),
|
|
353
|
+
// What the app did not tell Reticle, on the same rule the act path uses.
|
|
354
|
+
...(gaps.length > 0 ? { instrumentationGaps: gaps } : {}),
|
|
349
355
|
...advice,
|
|
350
356
|
...coverage,
|
|
351
357
|
...lastActSourceOnFailure(session, verdict.pass),
|
package/dist/tools/tool-kit.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { RealInputProvider } from '../input/real-input.js';
|
|
|
11
11
|
import type { BaselineStore } from '../project/baselines.js';
|
|
12
12
|
import type { RecordingStore } from '../flows/recordings.js';
|
|
13
13
|
import type { FileSystemPort } from '../project/fs-port.js';
|
|
14
|
+
import type { ArtifactRoot } from '../project/artifact-root.js';
|
|
14
15
|
import type { FlowStore } from '../flows/flows.js';
|
|
15
16
|
import type { ProjectStore } from '../project/project-store.js';
|
|
16
17
|
import type { AnnotationStore } from '../flows/annotation-store.js';
|
|
@@ -39,8 +40,22 @@ export interface ToolDeps<Ext = unknown> {
|
|
|
39
40
|
realInput?: RealInputProvider;
|
|
40
41
|
/** injected filesystem seam (tests pass a fake/temp-dir adapter). */
|
|
41
42
|
fs: FileSystemPort;
|
|
42
|
-
/** absolute.reticle path (index.ts computes cwd/.reticle). */
|
|
43
|
+
/** absolute.reticle path (index.ts computes cwd/.reticle). The FALLBACK, not the answer. */
|
|
43
44
|
reticleRoot: string;
|
|
45
|
+
/**
|
|
46
|
+
* Which `.reticle` a SESSION's artifacts belong in, resolved per call.
|
|
47
|
+
*
|
|
48
|
+
* `reticleRoot` above is fixed at construction from the daemon's own `process.cwd()`, which is a
|
|
49
|
+
* statement about where the daemon was launched and was being read as a statement about which
|
|
50
|
+
* project is being verified. Those are usually not the same tree: an editor starts a user-scoped
|
|
51
|
+
* MCP server wherever it likes, so flows landed outside the repo they verify, or died on
|
|
52
|
+
* `mkdir '/.reticle'`.
|
|
53
|
+
*
|
|
54
|
+
* Optional because every existing construction of ToolDeps predates it and because a consumer
|
|
55
|
+
* embedding this engine may not have a filesystem to search. Absent ⇒ `reticleRoot`, which is
|
|
56
|
+
* exactly today's behaviour.
|
|
57
|
+
*/
|
|
58
|
+
artifactRootFor?: (projectId: string | undefined) => ArtifactRoot;
|
|
44
59
|
/**
|
|
45
60
|
* This daemon's OWN project id, derived from the directory it was started in.
|
|
46
61
|
*
|
package/docs/README.md
CHANGED
|
@@ -69,6 +69,7 @@ Everything here is published to [docs.reticle.sh](https://docs.reticle.sh) by `d
|
|
|
69
69
|
| [vs-screenshots.mdx](vs-screenshots.mdx) | why a better vision model does not fix a non-visual bug |
|
|
70
70
|
| [telemetry.md](telemetry.md) | what is collected, and how to turn it off |
|
|
71
71
|
| [local-registry.md](local-registry.md) | installing an unpublished build |
|
|
72
|
+
| [license-activation.mdx](license-activation.mdx) | you have a key: where to set it, how to confirm it, what to do when it fails |
|
|
72
73
|
| [enterprise.md](enterprise.md) | the licensed surface |
|
|
73
74
|
|
|
74
75
|
## For people working on Reticle
|
package/docs/docs.json
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"navigation": {
|
|
33
33
|
"versions": [
|
|
34
34
|
{
|
|
35
|
-
"version": "2.
|
|
35
|
+
"version": "2.11.0",
|
|
36
36
|
"default": true,
|
|
37
37
|
"tabs": [
|
|
38
38
|
{
|
|
@@ -183,7 +183,7 @@
|
|
|
183
183
|
},
|
|
184
184
|
{
|
|
185
185
|
"group": "Trust & licensing",
|
|
186
|
-
"pages": ["telemetry", "enterprise"]
|
|
186
|
+
"pages": ["telemetry", "license-activation", "enterprise"]
|
|
187
187
|
}
|
|
188
188
|
]
|
|
189
189
|
},
|
package/docs/enterprise.md
CHANGED
|
@@ -8,6 +8,8 @@ Enterprise features ship inside the open package and are unlocked by a signed li
|
|
|
8
8
|
|
|
9
9
|
> Premium access (how you get + activate it), what's gated, the security/data-handling posture, and the licensing model. Integration mechanics live in [`platform-integration.md`](./platform-integration.md).
|
|
10
10
|
|
|
11
|
+
> **Already have a key and just need to switch it on?** [Activating a license key](./license-activation) is the step-by-step version: where to set it on each platform, the editor and CI cases, how to read every status, and what to do when it does not work.
|
|
12
|
+
|
|
11
13
|
## How premium access works (offline, no phone-home)
|
|
12
14
|
|
|
13
15
|
Enterprise (`ee/`) features ship **inside the open package**; they're source-available (free for development, testing, and evaluation). A **license key activates them in production**. Activation is verified locally with Ed25519; nothing about your usage ever leaves your machine.
|
|
@@ -29,7 +31,7 @@ Enterprise (`ee/`) features ship **inside the open package**; they're source-ava
|
|
|
29
31
|
expired license expired. Renew with hey@reticle.sh to keep using enterprise features
|
|
30
32
|
```
|
|
31
33
|
|
|
32
|
-
Every status also reports `gated
|
|
34
|
+
Every status reports `contact`, so a reader who is stuck always has somebody to write to. An unlicensed status also reports `gated`, what a licence unlocks in the build you are running, so the command answers "what would this buy me, and how do I get one" without anybody having to find this page first. It is derived from the features the gate actually enforces, so it names what your build will really refuse rather than a roadmap. An `active` licence does not report it: that reader has already bought, and listing feature names at them answers nothing they asked.
|
|
33
35
|
|
|
34
36
|
4. **Unlock.** Enterprise features now run in production; without a valid key they refuse to run there (a clear error, never a silent half-feature). In eval/dev they always run free.
|
|
35
37
|
5. **Renew.** Keys carry an expiry; `reticle license` warns before it lapses.
|