@reticlehq/server 2.5.0 → 2.6.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 +27 -10
- package/dist/bridge/bridge.d.ts +12 -0
- package/dist/bridge/bridge.js +32 -2
- package/dist/bridge/bridge.test-harness.d.ts +8 -0
- package/dist/bridge/bridge.test-harness.js +9 -1
- package/dist/cli/cli-doctor.d.ts +0 -5
- package/dist/cli/cli-doctor.js +71 -3
- package/dist/cli/cli-parse-feedback.d.ts +31 -0
- package/dist/cli/cli-parse-feedback.js +56 -0
- package/dist/cli/cli-parse.d.ts +5 -9
- package/dist/cli/cli-parse.js +7 -23
- package/dist/cli/doctor-daemon-line.d.ts +37 -0
- package/dist/cli/doctor-daemon-line.js +35 -0
- package/dist/cli/port-holder.d.ts +43 -0
- package/dist/cli/port-holder.js +99 -0
- package/dist/cli.js +131 -11
- package/dist/daemon/daemon-resilience.d.ts +24 -0
- package/dist/daemon/daemon-resilience.js +105 -17
- package/dist/daemon/daemon.js +26 -1
- package/dist/daemon/heartbeat.d.ts +115 -0
- package/dist/daemon/heartbeat.js +141 -0
- package/dist/daemon/idle-shutdown.d.ts +13 -5
- package/dist/daemon/idle-shutdown.js +14 -6
- package/dist/daemon/port-presence.d.ts +76 -0
- package/dist/daemon/port-presence.js +93 -0
- package/dist/daemon/state-dir.d.ts +7 -0
- package/dist/daemon/state-dir.js +59 -0
- package/dist/daemon/unclean-predecessor.d.ts +23 -0
- package/dist/daemon/unclean-predecessor.js +27 -0
- package/dist/events/predicate-eval.d.ts +10 -0
- package/dist/events/predicate-eval.js +24 -4
- package/dist/events/predicate-parse.d.ts +23 -0
- package/dist/events/predicate-parse.js +54 -0
- package/dist/events/predicate.d.ts +5 -0
- package/dist/events/predicate.js +17 -3
- package/dist/events/ring-buffer.js +1 -16
- package/dist/flows/flow-replay.d.ts +1 -1
- package/dist/flows/flow-replay.js +19 -2
- package/dist/flows/flow-step-runners.js +2 -3
- package/dist/flows/flow-tools.js +10 -14
- package/dist/flows/role-drift-reason.d.ts +20 -0
- package/dist/flows/role-drift-reason.js +39 -0
- package/dist/flows/suite-flakes.d.ts +33 -0
- package/dist/flows/suite-flakes.js +32 -0
- package/dist/honesty/blind-spots.d.ts +19 -0
- package/dist/honesty/blind-spots.js +33 -0
- package/dist/honesty/verified.d.ts +10 -1
- package/dist/honesty/verified.js +16 -0
- package/dist/http-server.js +9 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +10 -1
- package/dist/init/agent-rules.d.ts +18 -0
- package/dist/init/agent-rules.js +22 -4
- package/dist/init/cra.d.ts +13 -0
- package/dist/init/cra.js +16 -0
- package/dist/init/detect.d.ts +6 -0
- package/dist/init/detect.js +8 -0
- package/dist/init/mcp-clients.d.ts +81 -0
- package/dist/init/mcp-clients.js +248 -0
- package/dist/init/node-io.js +8 -2
- package/dist/init/plan-framework.js +36 -2
- package/dist/init/plan.d.ts +14 -0
- package/dist/init/plan.js +73 -9
- package/dist/init/run.d.ts +18 -1
- package/dist/init/run.js +133 -16
- package/dist/init/windows-quote.d.ts +38 -0
- package/dist/init/windows-quote.js +70 -0
- package/dist/mcp/mcp-outage.d.ts +1 -0
- package/dist/mcp/mcp-outage.js +8 -1
- package/dist/mcp/mcp-proxy.d.ts +11 -1
- package/dist/mcp/mcp-proxy.js +30 -7
- package/dist/mcp/mcp.js +29 -2
- package/dist/pool/browser-pool.d.ts +9 -0
- package/dist/pool/browser-pool.js +38 -9
- package/dist/session/attachment-history.d.ts +46 -0
- package/dist/session/attachment-history.js +65 -0
- package/dist/session/no-session-diagnosis.d.ts +7 -0
- package/dist/session/no-session-diagnosis.js +53 -3
- package/dist/session/no-session-watch.d.ts +3 -1
- package/dist/session/no-session-watch.js +10 -2
- package/dist/session/pending-commands.js +1 -0
- package/dist/session/session-info.d.ts +17 -1
- package/dist/session/session-info.js +5 -1
- package/dist/session/session-manager.d.ts +3 -0
- package/dist/session/session-manager.js +72 -3
- package/dist/session/session.d.ts +1 -0
- package/dist/session/session.js +8 -0
- package/dist/telemetry/crash-cause.d.ts +47 -0
- package/dist/telemetry/crash-cause.js +104 -0
- package/dist/telemetry/daemon-telemetry.d.ts +25 -7
- package/dist/telemetry/daemon-telemetry.js +28 -13
- package/dist/telemetry/error-class.d.ts +26 -0
- package/dist/telemetry/error-class.js +52 -0
- package/dist/telemetry/feedback-cli.d.ts +7 -2
- package/dist/telemetry/feedback-cli.js +49 -5
- package/dist/telemetry/feedback-context.d.ts +1 -6
- package/dist/telemetry/feedback-context.js +64 -4
- package/dist/telemetry/git-facts.d.ts +7 -0
- package/dist/telemetry/git-facts.js +4 -4
- package/dist/telemetry/project-profile.js +2 -1
- package/dist/telemetry/session-metrics.d.ts +45 -3
- package/dist/telemetry/session-metrics.js +247 -9
- package/dist/telemetry/telemetry.d.ts +19 -1
- package/dist/telemetry/telemetry.js +31 -8
- package/dist/tools/act-tools.js +33 -6
- package/dist/tools/assert-verdict.d.ts +7 -1
- package/dist/tools/assert-verdict.js +15 -3
- package/dist/tools/browser-tools.js +8 -2
- package/dist/tools/contract-tools.d.ts +0 -5
- package/dist/tools/contract-tools.js +26 -0
- package/dist/tools/dynamic-tools.js +6 -1
- package/dist/tools/error-recovery.d.ts +14 -0
- package/dist/tools/error-recovery.js +83 -7
- package/dist/tools/feedback-invite.d.ts +48 -0
- package/dist/tools/feedback-invite.js +71 -0
- package/dist/tools/invoke-tool.d.ts +0 -6
- package/dist/tools/invoke-tool.js +69 -1
- package/dist/tools/navigate-arrival.d.ts +26 -0
- package/dist/tools/navigate-arrival.js +60 -0
- package/dist/tools/navigate-result.d.ts +15 -1
- package/dist/tools/navigate-result.js +17 -10
- package/dist/tools/observe-tools.js +22 -4
- package/dist/tools/real-input-attempt.js +10 -5
- package/dist/tools/tool-kit.d.ts +8 -0
- package/dist/tools/tool-surface.js +13 -0
- package/docs/README.md +55 -0
- package/docs/agent-cheatsheet.md +2 -2
- package/docs/benchmarks.md +1 -1
- package/docs/fixtures-dispatch-receiver.yml +76 -0
- package/docs/fixtures.md +17 -0
- package/docs/gate-plan.md +98 -0
- package/docs/gates.md +117 -0
- package/docs/local-registry.md +1 -1
- package/docs/matrix/2.5.0/claude-code-machine.json +19 -0
- package/docs/matrix/2.5.0/codex-machine.json +20 -0
- package/docs/matrix/2.5.0/cursor-machine.json +22 -0
- package/docs/matrix/2.5.0/gemini-machine.json +22 -0
- package/docs/matrix/2.5.0/opencode-machine.json +22 -0
- package/docs/matrix/2.5.0/vscode-machine.json +22 -0
- package/docs/matrix/2.5.0/windsurf-machine.json +22 -0
- package/docs/matrix/MATRIX.md +26 -0
- package/docs/matrix/README.md +59 -0
- package/docs/mint.json +1 -1
- package/docs/system-map.md +152 -0
- package/docs/telemetry-contract.md +2 -2
- package/docs/telemetry.md +7 -0
- package/package.json +2 -2
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
import { McpScope, type Feedback } from '@reticlehq/core';
|
|
2
2
|
/** The context fields — the whole `Feedback` shape minus what the author supplies. */
|
|
3
3
|
export type FeedbackContext = Pick<Feedback, 'stack' | 'stackMajor' | 'runtime' | 'engine' | 'driver' | 'client' | 'clientVersion' | 'mcpScope'>;
|
|
4
|
-
/**
|
|
5
|
-
* The project's framework and its MAJOR version, or `{}` when package.json is absent, unreadable, or
|
|
6
|
-
* names nothing we recognize. The major comes from `parseMajor` — the same range parser `reticle init`
|
|
7
|
-
* uses to decide React 19 source mapping — so a range like `^15.0.0-canary.3` narrows the same way in
|
|
8
|
-
* both places instead of via a second, subtly different reader.
|
|
9
|
-
*/
|
|
10
4
|
export declare function detectStack(cwd: string, read?: (path: string) => string): {
|
|
11
5
|
stack?: string;
|
|
12
6
|
stackMajor?: number;
|
|
7
|
+
stackSource?: 'cwd' | 'workspace';
|
|
13
8
|
};
|
|
14
9
|
/**
|
|
15
10
|
* User scope unless the project checks in its own MCP registration. `reticle init` deliberately
|
|
@@ -9,10 +9,11 @@
|
|
|
9
9
|
* holds to. Detection is entirely best-effort: an unreadable package.json or an SDK too old to report
|
|
10
10
|
* its runtime yields `undefined`, never a throw and never a guess.
|
|
11
11
|
*/
|
|
12
|
-
import { readFileSync } from 'node:fs';
|
|
13
|
-
import { join } from 'node:path';
|
|
12
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
13
|
+
import { isAbsolute, join } from 'node:path';
|
|
14
14
|
import { AppRuntime, McpScope, PageDriver } from '@reticlehq/core';
|
|
15
15
|
import { parseMajor } from '../init/detect.js';
|
|
16
|
+
import { findWorkspaceApps } from '../init/run.js';
|
|
16
17
|
/**
|
|
17
18
|
* Dependency → reported stack, in resolution order. Meta-frameworks come FIRST: a Next app also
|
|
18
19
|
* depends on react, and reporting `react` for it would collapse the single most important segment
|
|
@@ -44,10 +45,10 @@ const PROJECT_MCP_FILES = ['.mcp.json', join('.cursor', 'mcp.json'), join('.vsco
|
|
|
44
45
|
* uses to decide React 19 source mapping — so a range like `^15.0.0-canary.3` narrows the same way in
|
|
45
46
|
* both places instead of via a second, subtly different reader.
|
|
46
47
|
*/
|
|
47
|
-
|
|
48
|
+
function stackOfManifest(dir, read) {
|
|
48
49
|
let pkg;
|
|
49
50
|
try {
|
|
50
|
-
pkg = JSON.parse(read(join(
|
|
51
|
+
pkg = JSON.parse(read(join(dir, PACKAGE_JSON)));
|
|
51
52
|
}
|
|
52
53
|
catch {
|
|
53
54
|
return {};
|
|
@@ -62,6 +63,65 @@ export function detectStack(cwd, read = readFile) {
|
|
|
62
63
|
}
|
|
63
64
|
return {};
|
|
64
65
|
}
|
|
66
|
+
export function detectStack(cwd, read = readFile) {
|
|
67
|
+
// The cwd wins when it IS an app — never redirect away from the real answer.
|
|
68
|
+
const here = stackOfManifest(cwd, read);
|
|
69
|
+
if (here.stack !== undefined)
|
|
70
|
+
return { ...here, stackSource: 'cwd' };
|
|
71
|
+
// Otherwise look for the app. The daemon's cwd is wherever the agent's client happened to launch,
|
|
72
|
+
// which for a real repo is the ROOT while the app sits in `frontend/`, `web/`, or a declared
|
|
73
|
+
// workspace. Reading one directory and giving up is why this returned nothing for every project
|
|
74
|
+
// that actually had Reticle set up: 0 of 77 instrumented projects, and 0 of 166 `size: huge` ones.
|
|
75
|
+
// The bigger the repo, the more certainly we failed.
|
|
76
|
+
//
|
|
77
|
+
// `findWorkspaceApps` is init's discovery, reused rather than reimplemented: it already reads
|
|
78
|
+
// declared workspaces (pnpm-workspace.yaml, package.json `workspaces`) AND scans top-level
|
|
79
|
+
// directories, and it was widened once already for a repo shape a user hit twice. A second
|
|
80
|
+
// detector here would drift from it the first time either changed.
|
|
81
|
+
try {
|
|
82
|
+
for (const app of findWorkspaceApps(profileIo(cwd))) {
|
|
83
|
+
const found = stackOfManifest(join(cwd, app), read);
|
|
84
|
+
// Reported as `workspace` so the share of projects needing discovery is measurable — that
|
|
85
|
+
// number is what says whether our inference needs an agent to correct it.
|
|
86
|
+
if (found.stack !== undefined)
|
|
87
|
+
return { ...found, stackSource: 'workspace' };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// Discovery touches the filesystem; a permission error must never cost us the profile event.
|
|
92
|
+
}
|
|
93
|
+
return {};
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The three filesystem methods `findWorkspaceApps` needs, rooted at `cwd`.
|
|
97
|
+
*
|
|
98
|
+
* Deliberately not the whole `InitIo`: this path must not be able to write, exec or print. What it
|
|
99
|
+
* cannot do is a stronger guarantee than a rule saying it shouldn't.
|
|
100
|
+
*/
|
|
101
|
+
function profileIo(cwd) {
|
|
102
|
+
const abs = (rel) => (isAbsolute(rel) ? rel : join(cwd, rel));
|
|
103
|
+
return {
|
|
104
|
+
readFile: (rel) => {
|
|
105
|
+
try {
|
|
106
|
+
return readFileSync(abs(rel), 'utf8');
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
exists: (rel) => existsSync(abs(rel)),
|
|
113
|
+
listDirs: (rel) => {
|
|
114
|
+
try {
|
|
115
|
+
return readdirSync(abs(rel), { withFileTypes: true })
|
|
116
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.') && 'node_modules' !== e.name)
|
|
117
|
+
.map((e) => e.name);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
65
125
|
/**
|
|
66
126
|
* User scope unless the project checks in its own MCP registration. `reticle init` deliberately
|
|
67
127
|
* registers ONCE at user scope, so `project` here means the team wired it up by hand — which is
|
|
@@ -6,6 +6,13 @@ export interface GitFacts {
|
|
|
6
6
|
origin?: string;
|
|
7
7
|
/** Which forge hosts it, bucketed. Absent unless state is `remote`. */
|
|
8
8
|
forge?: RepoForge;
|
|
9
|
+
/**
|
|
10
|
+
* The directory holding `.git` — the repo boundary. Absent only when state is `none`.
|
|
11
|
+
*
|
|
12
|
+
* Never sent: it is a local path. It exists so an UNPUSHED repo can still key its projectId to
|
|
13
|
+
* something stable across every directory inside it, which a raw cwd is not.
|
|
14
|
+
*/
|
|
15
|
+
root?: string;
|
|
9
16
|
}
|
|
10
17
|
/** `git@github.com:Acme/Web.git` / `https://u:p@github.com/Acme/Web` → `github.com/acme/web`. */
|
|
11
18
|
export declare const normalizeGitOrigin: (url: string) => string;
|
|
@@ -88,17 +88,17 @@ export function gitFacts(cwd, read = defaultRead, exists = defaultExists) {
|
|
|
88
88
|
}
|
|
89
89
|
catch {
|
|
90
90
|
// A `.git` with no readable config is still a repo — report it as one rather than as absent.
|
|
91
|
-
return { state: GitState.LOCAL_ONLY };
|
|
91
|
+
return { state: GitState.LOCAL_ONLY, root: dir };
|
|
92
92
|
}
|
|
93
93
|
// The url line inside the [remote "origin"] section — the first url after that header wins.
|
|
94
94
|
const section = config.split(/\[remote\s+"origin"\]/)[1];
|
|
95
95
|
const url = section?.match(/^\s*url\s*=\s*(.+)$/m)?.[1];
|
|
96
96
|
if (url === undefined)
|
|
97
|
-
return { state: GitState.LOCAL_ONLY };
|
|
97
|
+
return { state: GitState.LOCAL_ONLY, root: dir };
|
|
98
98
|
const origin = normalizeGitOrigin(url);
|
|
99
99
|
if ('' === origin)
|
|
100
|
-
return { state: GitState.LOCAL_ONLY };
|
|
101
|
-
return { state: GitState.REMOTE, origin, forge: forgeOf(origin) };
|
|
100
|
+
return { state: GitState.LOCAL_ONLY, root: dir };
|
|
101
|
+
return { state: GitState.REMOTE, origin, forge: forgeOf(origin), root: dir };
|
|
102
102
|
}
|
|
103
103
|
const parent = dirname(dir);
|
|
104
104
|
if (parent === dir)
|
|
@@ -180,13 +180,14 @@ export function profileProject(cwd, now) {
|
|
|
180
180
|
featuresUsed.push(FeatureFamily.BUG_CAPSULES);
|
|
181
181
|
if (hasHistory)
|
|
182
182
|
featuresUsed.push(FeatureFamily.CROSS_RUN_HISTORY);
|
|
183
|
-
const { stack, stackMajor } = detectStack(cwd);
|
|
183
|
+
const { stack, stackMajor, stackSource } = detectStack(cwd);
|
|
184
184
|
const ageWeeks = projectAgeWeeks(cwd, now);
|
|
185
185
|
const git = gitFacts(cwd);
|
|
186
186
|
return {
|
|
187
187
|
git: git.state,
|
|
188
188
|
...(git.forge !== undefined ? { forge: git.forge } : {}),
|
|
189
189
|
...(stack !== undefined ? { stack } : {}),
|
|
190
|
+
...(stackSource !== undefined ? { stackSource } : {}),
|
|
190
191
|
...(stackMajor !== undefined ? { stackMajor } : {}),
|
|
191
192
|
size: sizeBucket(countSourceFiles(cwd)),
|
|
192
193
|
monorepo: isMonorepo(cwd),
|
|
@@ -41,7 +41,15 @@ export declare class SessionMetrics {
|
|
|
41
41
|
*/
|
|
42
42
|
recordBrowserLatency(ms: number): void;
|
|
43
43
|
/** A call for a tool that does not exist. Non-zero means our surface is confusing the agent. */
|
|
44
|
-
|
|
44
|
+
/**
|
|
45
|
+
* An agent reached for a tool that does not exist — and WHAT it reached for.
|
|
46
|
+
*
|
|
47
|
+
* The name is the point. A count says the surface confused someone; the name says which
|
|
48
|
+
* capability they expected us to have, in their own vocabulary. Safe to keep: it is a name from
|
|
49
|
+
* our own namespace, never app data. Bounded and truncated, because a guess is a short
|
|
50
|
+
* identifier and anything longer is not one.
|
|
51
|
+
*/
|
|
52
|
+
recordUnknownTool(name?: string): void;
|
|
45
53
|
recordToolCall(tool: string, args?: Record<string, unknown>): void;
|
|
46
54
|
/**
|
|
47
55
|
* One failed tool call. Grouped by fingerprint, but stored WITH its skeleton message and the tool
|
|
@@ -62,6 +70,26 @@ export declare class SessionMetrics {
|
|
|
62
70
|
breadcrumb: string[];
|
|
63
71
|
inFlight: string | undefined;
|
|
64
72
|
};
|
|
73
|
+
/**
|
|
74
|
+
* Ask the agent for a verdict, once, when it has driven the page without asking for one.
|
|
75
|
+
*
|
|
76
|
+
* Measured 2026-08-10/11: of 170 sessions that made a tool call, 140 produced no verdict — and
|
|
77
|
+
* **137 of those never called a verdict-producing tool even once.** They drove the app 269 times
|
|
78
|
+
* with `reticle_act` and never asked whether it worked. This counter already existed
|
|
79
|
+
* (`abandonedActions`, non-zero in 58 sessions); it was reported to US and never to the agent.
|
|
80
|
+
*
|
|
81
|
+
* Three is the threshold because the median verdict-less session made four tool calls: any higher
|
|
82
|
+
* and it never fires for the sessions that need it. Re-arms after a verdict, so a second
|
|
83
|
+
* abandoned run is caught — the loop can break more than once in a long session.
|
|
84
|
+
*/
|
|
85
|
+
takeUnverifiedNudge(): string | undefined;
|
|
86
|
+
/**
|
|
87
|
+
* An app's SDK dialled this daemon.
|
|
88
|
+
*
|
|
89
|
+
* Zero of these at shutdown means the daemon ran and no app ever connected — a broken install.
|
|
90
|
+
* Non-zero with no tool calls is the opposite problem. See SessionSummary.appConnects.
|
|
91
|
+
*/
|
|
92
|
+
recordAppConnected(): void;
|
|
65
93
|
/** One action driven at the page. Settled by the next verdict; see #unsettledActions. */
|
|
66
94
|
recordAction(): void;
|
|
67
95
|
recordVerification(): void;
|
|
@@ -82,12 +110,26 @@ export declare class SessionMetrics {
|
|
|
82
110
|
* express failure misses the only question worth asking: how often can people not get a browser?
|
|
83
111
|
*/
|
|
84
112
|
recordConnectAttempt(kind: BrowserLaunchKind): (failure?: ConnectFailure) => void;
|
|
85
|
-
recordClient(name: string): void;
|
|
113
|
+
recordClient(name: string, version?: string): void;
|
|
114
|
+
/**
|
|
115
|
+
* The agent's client went away.
|
|
116
|
+
*
|
|
117
|
+
* This is the only part of `endReason` that a query cannot derive from the counters: "the client
|
|
118
|
+
* detached" and "the agent stopped asking" produce identical numbers and are different findings —
|
|
119
|
+
* one is the task ending, the other is us losing them.
|
|
120
|
+
*/
|
|
121
|
+
recordClientLeft(): void;
|
|
122
|
+
/** How many times in a row the current tool has been called. Read by the friction invite. */
|
|
123
|
+
get currentRun(): number;
|
|
124
|
+
/** Reticle invited the agent to send feedback. The denominator for feedback_submitted. */
|
|
125
|
+
recordFeedbackPrompt(): void;
|
|
126
|
+
/** Which tool surface was advertised to agents this session. */
|
|
127
|
+
recordSurface(surface: string): void;
|
|
86
128
|
/**
|
|
87
129
|
* Roll the counters into the event payload. `final` marks a clean shutdown; a periodic flush passes
|
|
88
130
|
* false so a session killed before it can exit is not lost entirely (see the flush note in cli.ts).
|
|
89
131
|
*/
|
|
90
|
-
summarize(final: boolean): SessionSummary;
|
|
132
|
+
summarize(final: boolean, exit?: SessionSummary['exit']): SessionSummary;
|
|
91
133
|
/** True when nothing at all happened — a flush of an idle daemon is not worth an event. */
|
|
92
134
|
get empty(): boolean;
|
|
93
135
|
/** Zero the counters after a non-final flush so the next flush reports the NEXT window, not a total. */
|
|
@@ -19,6 +19,7 @@ import { BrowserLaunchKind, } from '@reticlehq/core';
|
|
|
19
19
|
import { errorSkeleton, fingerprintError } from './error-fingerprint.js';
|
|
20
20
|
import { describeToolParams } from './argument-shape.js';
|
|
21
21
|
import { machineSnapshot } from './machine-snapshot.js';
|
|
22
|
+
import { classifyError } from './error-class.js';
|
|
22
23
|
/** Cap the distinct error shapes held in memory — a pathological loop must not grow unbounded. */
|
|
23
24
|
const MAX_ERROR_KINDS = 40;
|
|
24
25
|
/** Distinct tool names whose repeat-runs are tracked. Its own cap: the tool surface, not error shapes. */
|
|
@@ -46,6 +47,13 @@ const MAX_PARAMS_PER_TOOL = 24;
|
|
|
46
47
|
* strings and costs nothing to hold.
|
|
47
48
|
*/
|
|
48
49
|
const BREADCRUMB_LENGTH = 12;
|
|
50
|
+
/**
|
|
51
|
+
* Actions driven with no verdict before Reticle asks for one.
|
|
52
|
+
*
|
|
53
|
+
* Three, because the median verdict-less session made FOUR tool calls (2026-08-10/11). A higher
|
|
54
|
+
* threshold never fires for the sessions that need it most.
|
|
55
|
+
*/
|
|
56
|
+
const UNVERIFIED_ACTION_NUDGE_AT = 3;
|
|
49
57
|
/**
|
|
50
58
|
* Mutable counters for one daemon lifetime. A plain class, not a store: it is process-local, it never
|
|
51
59
|
* persists, and it dies with the daemon.
|
|
@@ -54,6 +62,27 @@ export class SessionMetrics {
|
|
|
54
62
|
#toolCalls = 0;
|
|
55
63
|
#toolErrors = 0;
|
|
56
64
|
#verifications = 0;
|
|
65
|
+
/**
|
|
66
|
+
* Session-LIFETIME totals for the four numbers the funnel is computed from.
|
|
67
|
+
*
|
|
68
|
+
* `reset()` zeroes every window counter on each periodic flush, but `durationMs` is measured from
|
|
69
|
+
* `#startedAt` and is never reset. So the FINAL summary — the one whose docstring promises "the
|
|
70
|
+
* whole session in a single event" — reported a 30-minute session containing zero tool calls,
|
|
71
|
+
* because it only ever carried the residue since the last flush.
|
|
72
|
+
*
|
|
73
|
+
* Measured 2026-08-10/11: **496 of 499 `daemon_stopped` rows had `toolCalls: 0`**, at a median
|
|
74
|
+
* duration of 30.5 minutes, while `session_progress` for the same daemons carried real tool
|
|
75
|
+
* histograms. Anyone computing "did this session use Reticle" off the event named for the end of
|
|
76
|
+
* the session got `no` essentially always. That is not a small skew — it is the funnel reading
|
|
77
|
+
* zero at the exact step this release exists to raise.
|
|
78
|
+
*
|
|
79
|
+
* Window semantics are unchanged: `session_progress` still reports its own window, so nothing that
|
|
80
|
+
* already sums those events double-counts. Only `final: true` swaps in the lifetime numbers.
|
|
81
|
+
*/
|
|
82
|
+
#lifetimeToolCalls = 0;
|
|
83
|
+
#lifetimeToolErrors = 0;
|
|
84
|
+
#lifetimeVerifications = 0;
|
|
85
|
+
#lifetimeBugsFound = 0;
|
|
57
86
|
#toolCounts = new Map();
|
|
58
87
|
#toolParams = new Map();
|
|
59
88
|
/** Ring of the most recent tool NAMES — the agent's approach run, for a crash report. */
|
|
@@ -69,6 +98,12 @@ export class SessionMetrics {
|
|
|
69
98
|
#concurrent = 0;
|
|
70
99
|
#peakConcurrent = 0;
|
|
71
100
|
#unknownToolCalls = 0;
|
|
101
|
+
/**
|
|
102
|
+
* Names the agent guessed at, session-LIFETIME — a guess made early must still be reported at
|
|
103
|
+
* the end, so this is deliberately not cleared by a flush.
|
|
104
|
+
*/
|
|
105
|
+
#unknownTools = new Map();
|
|
106
|
+
#lifetimeUnknownToolCalls = 0;
|
|
72
107
|
#bugsFound = 0;
|
|
73
108
|
#browserMs = 0;
|
|
74
109
|
#browserCommands = 0;
|
|
@@ -101,7 +136,33 @@ export class SessionMetrics {
|
|
|
101
136
|
* run, which is exactly what the field claims to mean.
|
|
102
137
|
*/
|
|
103
138
|
#unsettledActions = 0;
|
|
139
|
+
/** One-shot latch for the verdict nudge — see takeUnverifiedNudge. */
|
|
140
|
+
#nudgedUnverified = false;
|
|
141
|
+
/** The agent detached. The one part of `endReason` a query cannot derive. */
|
|
142
|
+
#clientLeft = false;
|
|
143
|
+
/** Feedback invitations shown, session-lifetime — the denominator for feedback_submitted. */
|
|
144
|
+
#feedbackPrompted = 0;
|
|
145
|
+
/** Error buckets by whose defect. Session-lifetime: the mix is a fact about the session. */
|
|
146
|
+
#errorClasses = new Map();
|
|
147
|
+
/**
|
|
148
|
+
* The recovery hinge. A call's outcome is only known once the NEXT one starts, so the verdict for
|
|
149
|
+
* call N-1 is resolved at the start of call N (and for the last call, at summarize).
|
|
150
|
+
*/
|
|
151
|
+
#currentCallErrored = false;
|
|
152
|
+
#priorCallErrored = false;
|
|
153
|
+
#errorsRecovered = 0;
|
|
154
|
+
#errorsRepeated = 0;
|
|
155
|
+
/**
|
|
156
|
+
* App SDK connections, session-lifetime. NOT reset by a flush: "did an app ever connect" is a
|
|
157
|
+
* fact about the session, and a page reload after a flush must not erase it.
|
|
158
|
+
*/
|
|
159
|
+
#appConnects = 0;
|
|
160
|
+
#firstAppAt;
|
|
104
161
|
#clients = new Set();
|
|
162
|
+
/** Client name -> version, session-lifetime. Never cleared: who drove is a fact about the session. */
|
|
163
|
+
#clientVersions = new Map();
|
|
164
|
+
/** Which tool surface the daemon advertised. */
|
|
165
|
+
#surface;
|
|
105
166
|
#startedAt;
|
|
106
167
|
#now;
|
|
107
168
|
/** Clock injected — this file must stay testable without a real one, per the repo's clock rule. */
|
|
@@ -146,10 +207,36 @@ export class SessionMetrics {
|
|
|
146
207
|
this.#browserCommands += 1;
|
|
147
208
|
}
|
|
148
209
|
/** A call for a tool that does not exist. Non-zero means our surface is confusing the agent. */
|
|
149
|
-
|
|
210
|
+
/**
|
|
211
|
+
* An agent reached for a tool that does not exist — and WHAT it reached for.
|
|
212
|
+
*
|
|
213
|
+
* The name is the point. A count says the surface confused someone; the name says which
|
|
214
|
+
* capability they expected us to have, in their own vocabulary. Safe to keep: it is a name from
|
|
215
|
+
* our own namespace, never app data. Bounded and truncated, because a guess is a short
|
|
216
|
+
* identifier and anything longer is not one.
|
|
217
|
+
*/
|
|
218
|
+
recordUnknownTool(name) {
|
|
150
219
|
this.#unknownToolCalls += 1;
|
|
220
|
+
this.#lifetimeUnknownToolCalls += 1;
|
|
221
|
+
if (name === undefined || 0 === name.length)
|
|
222
|
+
return;
|
|
223
|
+
const key = name.slice(0, 64);
|
|
224
|
+
const seen = this.#unknownTools.get(key);
|
|
225
|
+
if (seen !== undefined) {
|
|
226
|
+
this.#unknownTools.set(key, seen + 1);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
// Stop growing once full: a pathological loop must not balloon the payload. The head is what
|
|
230
|
+
// gets built; the tail is noise.
|
|
231
|
+
if (this.#unknownTools.size >= MAX_ERROR_KINDS)
|
|
232
|
+
return;
|
|
233
|
+
this.#unknownTools.set(key, 1);
|
|
151
234
|
}
|
|
152
235
|
recordToolCall(tool, args) {
|
|
236
|
+
// `recordToolCall` runs BEFORE a handler and `recordToolError` after it fails, so a call's
|
|
237
|
+
// outcome is only known once the NEXT call starts. Resolve the one before it here; the final
|
|
238
|
+
// call is resolved in summarize.
|
|
239
|
+
this.#resolveRecovery();
|
|
153
240
|
// A run of the same tool back to back. Five calls to reticle_act is engagement; five in a row
|
|
154
241
|
// after four failures is an agent stuck, and toolCounts reports both as "5".
|
|
155
242
|
this.#currentRun = tool === this.#lastTool ? this.#currentRun + 1 : 1;
|
|
@@ -161,6 +248,7 @@ export class SessionMetrics {
|
|
|
161
248
|
this.#repeatRuns.set(tool, this.#currentRun);
|
|
162
249
|
}
|
|
163
250
|
this.#toolCalls += 1;
|
|
251
|
+
this.#lifetimeToolCalls += 1;
|
|
164
252
|
bump(this.#toolCounts, tool);
|
|
165
253
|
this.#inFlight = tool;
|
|
166
254
|
this.#breadcrumb.push(tool);
|
|
@@ -190,6 +278,11 @@ export class SessionMetrics {
|
|
|
190
278
|
*/
|
|
191
279
|
recordToolError(message, tool) {
|
|
192
280
|
this.#toolErrors += 1;
|
|
281
|
+
this.#lifetimeToolErrors += 1;
|
|
282
|
+
// Whose defect. Only the `schema` bucket is fixable by writing better descriptions, and it was
|
|
283
|
+
// indistinguishable from the rest inside one `toolErrors` count.
|
|
284
|
+
bump(this.#errorClasses, classifyError(message));
|
|
285
|
+
this.#currentCallErrored = true;
|
|
193
286
|
if (NO_SESSION_ERROR.test(message))
|
|
194
287
|
this.#noSessionErrors += 1;
|
|
195
288
|
const fingerprint = fingerprintError(message);
|
|
@@ -236,14 +329,51 @@ export class SessionMetrics {
|
|
|
236
329
|
get trail() {
|
|
237
330
|
return { breadcrumb: [...this.#breadcrumb], inFlight: this.#inFlight };
|
|
238
331
|
}
|
|
332
|
+
/**
|
|
333
|
+
* Ask the agent for a verdict, once, when it has driven the page without asking for one.
|
|
334
|
+
*
|
|
335
|
+
* Measured 2026-08-10/11: of 170 sessions that made a tool call, 140 produced no verdict — and
|
|
336
|
+
* **137 of those never called a verdict-producing tool even once.** They drove the app 269 times
|
|
337
|
+
* with `reticle_act` and never asked whether it worked. This counter already existed
|
|
338
|
+
* (`abandonedActions`, non-zero in 58 sessions); it was reported to US and never to the agent.
|
|
339
|
+
*
|
|
340
|
+
* Three is the threshold because the median verdict-less session made four tool calls: any higher
|
|
341
|
+
* and it never fires for the sessions that need it. Re-arms after a verdict, so a second
|
|
342
|
+
* abandoned run is caught — the loop can break more than once in a long session.
|
|
343
|
+
*/
|
|
344
|
+
takeUnverifiedNudge() {
|
|
345
|
+
if (this.#unsettledActions < UNVERIFIED_ACTION_NUDGE_AT || this.#nudgedUnverified) {
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
this.#nudgedUnverified = true;
|
|
349
|
+
return (`you have driven this page ${String(this.#unsettledActions)} times without asking for a ` +
|
|
350
|
+
'verdict. Only reticle_act_and_wait and reticle_assert produce one — everything else moves ' +
|
|
351
|
+
'or reads the app and proves nothing. Name the consequence you expect and check it: ' +
|
|
352
|
+
'reticle_assert({ sessionId, since, predicate }), or use reticle_act_and_wait({ ref, ' +
|
|
353
|
+
'action, until }) for the next step so the action and its check are one call.');
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* An app's SDK dialled this daemon.
|
|
357
|
+
*
|
|
358
|
+
* Zero of these at shutdown means the daemon ran and no app ever connected — a broken install.
|
|
359
|
+
* Non-zero with no tool calls is the opposite problem. See SessionSummary.appConnects.
|
|
360
|
+
*/
|
|
361
|
+
recordAppConnected() {
|
|
362
|
+
this.#appConnects += 1;
|
|
363
|
+
this.#firstAppAt ??= this.#now();
|
|
364
|
+
}
|
|
239
365
|
/** One action driven at the page. Settled by the next verdict; see #unsettledActions. */
|
|
240
366
|
recordAction() {
|
|
241
367
|
this.#unsettledActions += 1;
|
|
242
368
|
}
|
|
243
369
|
recordVerification() {
|
|
244
370
|
this.#verifications += 1;
|
|
371
|
+
this.#lifetimeVerifications += 1;
|
|
245
372
|
// A verdict settles whatever was outstanding, however many actions it took to get there.
|
|
246
373
|
this.#unsettledActions = 0;
|
|
374
|
+
// Re-arm the nudge: the loop can break more than once in an 11-hour session, and an agent that
|
|
375
|
+
// complied once and then drifted again is exactly the case worth catching.
|
|
376
|
+
this.#nudgedUnverified = false;
|
|
247
377
|
}
|
|
248
378
|
/**
|
|
249
379
|
* One defect found in the app under test — the outcome number, kept broken down by kind.
|
|
@@ -254,6 +384,7 @@ export class SessionMetrics {
|
|
|
254
384
|
*/
|
|
255
385
|
recordBug(kind) {
|
|
256
386
|
this.#bugsFound += 1;
|
|
387
|
+
this.#lifetimeBugsFound += 1;
|
|
257
388
|
if (this.#bugKinds.size < MAX_ERROR_KINDS || this.#bugKinds.has(kind))
|
|
258
389
|
bump(this.#bugKinds, kind);
|
|
259
390
|
const first = !this.#seenBugKinds.has(kind);
|
|
@@ -285,26 +416,76 @@ export class SessionMetrics {
|
|
|
285
416
|
stats.failures[failure] = (stats.failures[failure] ?? 0) + 1;
|
|
286
417
|
};
|
|
287
418
|
}
|
|
288
|
-
recordClient(name) {
|
|
419
|
+
recordClient(name, version) {
|
|
420
|
+
const key = name.slice(0, 64);
|
|
289
421
|
if (this.#clients.size < MAX_CLIENTS)
|
|
290
|
-
this.#clients.add(
|
|
422
|
+
this.#clients.add(key);
|
|
423
|
+
// The version was already in the handshake and was being dropped. A regression in agent
|
|
424
|
+
// behaviour is usually a client version, and without this it is unattributable.
|
|
425
|
+
if (version !== undefined && '' !== version && this.#clientVersions.size < MAX_CLIENTS) {
|
|
426
|
+
this.#clientVersions.set(key, version.slice(0, 32));
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* The agent's client went away.
|
|
431
|
+
*
|
|
432
|
+
* This is the only part of `endReason` that a query cannot derive from the counters: "the client
|
|
433
|
+
* detached" and "the agent stopped asking" produce identical numbers and are different findings —
|
|
434
|
+
* one is the task ending, the other is us losing them.
|
|
435
|
+
*/
|
|
436
|
+
recordClientLeft() {
|
|
437
|
+
this.#clientLeft = true;
|
|
438
|
+
}
|
|
439
|
+
/** How many times in a row the current tool has been called. Read by the friction invite. */
|
|
440
|
+
get currentRun() {
|
|
441
|
+
return this.#currentRun;
|
|
442
|
+
}
|
|
443
|
+
/** Reticle invited the agent to send feedback. The denominator for feedback_submitted. */
|
|
444
|
+
recordFeedbackPrompt() {
|
|
445
|
+
this.#feedbackPrompted += 1;
|
|
446
|
+
}
|
|
447
|
+
/** Which tool surface was advertised to agents this session. */
|
|
448
|
+
recordSurface(surface) {
|
|
449
|
+
this.#surface = surface.slice(0, 32);
|
|
291
450
|
}
|
|
292
451
|
/**
|
|
293
452
|
* Roll the counters into the event payload. `final` marks a clean shutdown; a periodic flush passes
|
|
294
453
|
* false so a session killed before it can exit is not lost entirely (see the flush note in cli.ts).
|
|
295
454
|
*/
|
|
296
|
-
summarize(final) {
|
|
455
|
+
summarize(final, exit) {
|
|
456
|
+
// The last call has no successor to resolve it, so resolve it here. Only on a FINAL summary:
|
|
457
|
+
// a periodic flush is not the end of anything and would resolve a call still in flight.
|
|
458
|
+
if (final)
|
|
459
|
+
this.#resolveRecovery();
|
|
297
460
|
const machine = machineSnapshot();
|
|
461
|
+
// A FINAL summary reports the session; a periodic flush reports its window. `durationMs` was
|
|
462
|
+
// always session-lifetime, so a windowed count next to it described a 30-minute session that
|
|
463
|
+
// made no calls. The histograms below stay windowed by design — summing them across a session's
|
|
464
|
+
// events is correct, and duplicating every map to make them lifetime would double the memory
|
|
465
|
+
// this class exists to keep small.
|
|
466
|
+
const scalars = final
|
|
467
|
+
? {
|
|
468
|
+
toolCalls: this.#lifetimeToolCalls,
|
|
469
|
+
toolErrors: this.#lifetimeToolErrors,
|
|
470
|
+
verifications: this.#lifetimeVerifications,
|
|
471
|
+
bugsFound: this.#lifetimeBugsFound,
|
|
472
|
+
}
|
|
473
|
+
: {
|
|
474
|
+
toolCalls: this.#toolCalls,
|
|
475
|
+
toolErrors: this.#toolErrors,
|
|
476
|
+
verifications: this.#verifications,
|
|
477
|
+
bugsFound: this.#bugsFound,
|
|
478
|
+
};
|
|
298
479
|
return {
|
|
299
480
|
durationMs: Math.max(0, this.#now() - this.#startedAt),
|
|
300
|
-
toolCalls:
|
|
481
|
+
toolCalls: scalars.toolCalls,
|
|
301
482
|
toolCounts: Object.fromEntries(this.#toolCounts),
|
|
302
|
-
toolErrors:
|
|
483
|
+
toolErrors: scalars.toolErrors,
|
|
303
484
|
...(this.#errorKinds.size > 0 ? { errors: [...this.#errorKinds.values()] } : {}),
|
|
304
485
|
sdkFailures: this.#sdkFailures,
|
|
305
486
|
...(this.#sdkFailureKinds.size > 0 ? { sdkErrors: [...this.#sdkFailureKinds.values()] } : {}),
|
|
306
|
-
verifications:
|
|
307
|
-
bugsFound:
|
|
487
|
+
verifications: scalars.verifications,
|
|
488
|
+
bugsFound: scalars.bugsFound,
|
|
308
489
|
...(this.#bugKinds.size > 0 ? { bugKinds: Object.fromEntries(this.#bugKinds) } : {}),
|
|
309
490
|
...(this.#noSessionErrors > 0 ? { noSessionErrors: this.#noSessionErrors } : {}),
|
|
310
491
|
...(this.#repeatRuns.size > 0
|
|
@@ -323,12 +504,69 @@ export class SessionMetrics {
|
|
|
323
504
|
browserMs: this.#browserMs,
|
|
324
505
|
browserCommands: this.#browserCommands,
|
|
325
506
|
peakConcurrentTools: this.#peakConcurrent,
|
|
326
|
-
unknownToolCalls: this.#unknownToolCalls,
|
|
507
|
+
unknownToolCalls: final ? this.#lifetimeUnknownToolCalls : this.#unknownToolCalls,
|
|
508
|
+
...(this.#unknownTools.size > 0
|
|
509
|
+
? { unknownTools: Object.fromEntries(this.#unknownTools) }
|
|
510
|
+
: {}),
|
|
327
511
|
...(machine !== undefined ? { machine } : {}),
|
|
328
512
|
...(this.#clients.size > 0 ? { clients: [...this.#clients] } : {}),
|
|
513
|
+
...(this.#clientVersions.size > 0
|
|
514
|
+
? { clientVersions: Object.fromEntries(this.#clientVersions) }
|
|
515
|
+
: {}),
|
|
516
|
+
...(this.#surface === undefined ? {} : { surface: this.#surface }),
|
|
517
|
+
...(final ? { endReason: this.#endReason() } : {}),
|
|
518
|
+
...(this.#feedbackPrompted > 0 ? { feedbackPrompted: this.#feedbackPrompted } : {}),
|
|
519
|
+
...(this.#errorClasses.size > 0
|
|
520
|
+
? { errorClasses: Object.fromEntries(this.#errorClasses) }
|
|
521
|
+
: {}),
|
|
522
|
+
...(this.#errorsRecovered > 0 ? { errorsRecovered: this.#errorsRecovered } : {}),
|
|
523
|
+
...(this.#errorsRepeated > 0 ? { errorsRepeated: this.#errorsRepeated } : {}),
|
|
524
|
+
// Always present, including zero: absence and "no app ever connected" must not look alike,
|
|
525
|
+
// because zero IS the finding here.
|
|
526
|
+
appConnects: this.#appConnects,
|
|
527
|
+
...(this.#firstAppAt === undefined
|
|
528
|
+
? {}
|
|
529
|
+
: { msToFirstApp: Math.max(0, this.#firstAppAt - this.#startedAt) }),
|
|
329
530
|
final,
|
|
531
|
+
// Only on a real exit. A periodic flush carrying `exit: "unknown"` would read as a daemon that
|
|
532
|
+
// died without a shutdown path, which is the one thing this field exists to make visible.
|
|
533
|
+
...(final && exit !== undefined ? { exit } : {}),
|
|
330
534
|
};
|
|
331
535
|
}
|
|
536
|
+
/**
|
|
537
|
+
* Decide whether the error before the previous call was recovered from or repeated.
|
|
538
|
+
*
|
|
539
|
+
* Called at the start of each new call (resolving the one before it) and once at summarize (for
|
|
540
|
+
* the last call). Shifting rather than counting on the spot is what makes "the agent tried
|
|
541
|
+
* something else and it worked" distinguishable from "it failed the same way again" — an earlier
|
|
542
|
+
* version counted a recovery the moment a call STARTED, so a call that then failed was recorded
|
|
543
|
+
* as a success.
|
|
544
|
+
*/
|
|
545
|
+
#resolveRecovery() {
|
|
546
|
+
if (this.#priorCallErrored) {
|
|
547
|
+
if (this.#currentCallErrored)
|
|
548
|
+
this.#errorsRepeated += 1;
|
|
549
|
+
else
|
|
550
|
+
this.#errorsRecovered += 1;
|
|
551
|
+
}
|
|
552
|
+
this.#priorCallErrored = this.#currentCallErrored;
|
|
553
|
+
this.#currentCallErrored = false;
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* What state the agent's work was in at the end. Ordered by which fact outranks which: a client
|
|
557
|
+
* that left explains everything after it, and a verdict settles whatever preceded it.
|
|
558
|
+
*/
|
|
559
|
+
#endReason() {
|
|
560
|
+
if (0 === this.#lifetimeToolCalls)
|
|
561
|
+
return 'never_used';
|
|
562
|
+
if (this.#clientLeft)
|
|
563
|
+
return 'client_left';
|
|
564
|
+
if (this.#unsettledActions > 0)
|
|
565
|
+
return 'abandoned';
|
|
566
|
+
if (this.#lifetimeVerifications > 0)
|
|
567
|
+
return 'verified';
|
|
568
|
+
return 'explored';
|
|
569
|
+
}
|
|
332
570
|
/** True when nothing at all happened — a flush of an idle daemon is not worth an event. */
|
|
333
571
|
get empty() {
|
|
334
572
|
return (0 === this.#toolCalls &&
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { TelemetryEventKind, type Crash, type Feedback, type Identity, type BugFound, type InitOutcome, type McpConnection, type McpOutage, type ProjectProfile, type SessionSummary, type TelemetryActor, type Verification, type VersionChange } from '@reticlehq/core';
|
|
1
|
+
import { TelemetryEventKind, ProjectIdSource, type Crash, type Feedback, type Identity, type BugFound, type InitOutcome, type McpConnection, type McpOutage, type ProjectProfile, type SessionSummary, type TelemetryActor, type Verification, type VersionChange } from '@reticlehq/core';
|
|
2
2
|
/**
|
|
3
3
|
* Feedback gets its own, much larger budget — and a retry.
|
|
4
4
|
*
|
|
@@ -15,6 +15,24 @@ import { TelemetryEventKind, type Crash, type Feedback, type Identity, type BugF
|
|
|
15
15
|
export declare const FEEDBACK_TIMEOUT_MS = 15000;
|
|
16
16
|
export declare const FEEDBACK_RETRIES = 1;
|
|
17
17
|
export declare const FEEDBACK_RETRY_BACKOFF_MS = 750;
|
|
18
|
+
/**
|
|
19
|
+
* One-way project fingerprint: distinct-count of this = "projects Reticle is used in", reveals nothing.
|
|
20
|
+
*
|
|
21
|
+
* Prefers a hash of the git ORIGIN over a hash of the cwd. The contract in `@reticlehq/core` always
|
|
22
|
+
* said "one-way hash of the git remote (or cwd)", but the implementation only ever hashed the path —
|
|
23
|
+
* which meant the same repo cloned by four teammates, or checked out twice on one machine, counted as
|
|
24
|
+
* four separate projects. That inflates the project count and, worse, makes team adoption look like
|
|
25
|
+
* unrelated individuals. Hashing the origin dedupes a real project down to one id across every clone.
|
|
26
|
+
*
|
|
27
|
+
* Still one-way, still non-reversible, and still exactly what the published policy describes: the
|
|
28
|
+
* remote URL itself never leaves the machine, only its SHA-256. The URL is normalized first (scheme,
|
|
29
|
+
* credentials, `.git` suffix and case removed) so `git@github.com:acme/web.git` and
|
|
30
|
+
* `https://github.com/Acme/web` are recognized as the same project rather than two.
|
|
31
|
+
*/
|
|
32
|
+
export declare const projectFingerprint: (cwd: string) => {
|
|
33
|
+
projectId: string;
|
|
34
|
+
source: ProjectIdSource;
|
|
35
|
+
};
|
|
18
36
|
/** The current telemetry state and which control set it — what `reticle telemetry status` prints. */
|
|
19
37
|
export declare const describeTelemetry: (env?: NodeJS.ProcessEnv, dir?: string, cwd?: string) => {
|
|
20
38
|
enabled: boolean;
|