@reticlehq/server 2.5.0 → 2.7.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 +226 -26
- package/dist/bridge/bridge.d.ts +18 -1
- package/dist/bridge/bridge.js +59 -6
- package/dist/bridge/bridge.test-harness.d.ts +8 -0
- package/dist/bridge/bridge.test-harness.js +9 -1
- package/dist/cli/chromium-hint.d.ts +40 -0
- package/dist/cli/chromium-hint.js +75 -0
- package/dist/cli/cli-doctor.d.ts +0 -5
- package/dist/cli/cli-doctor.js +74 -14
- package/dist/cli/cli-launch.d.ts +1 -0
- package/dist/cli/cli-launch.js +4 -1
- 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/cli-port.js +20 -1
- package/dist/cli/doctor-daemon-line.d.ts +37 -0
- package/dist/cli/doctor-daemon-line.js +44 -0
- package/dist/cli/port-holder.d.ts +43 -0
- package/dist/cli/port-holder.js +99 -0
- package/dist/cli.js +170 -19
- 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/loopback-alias.d.ts +32 -0
- package/dist/daemon/loopback-alias.js +80 -0
- 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 +17 -3
- package/dist/events/predicate-eval.js +183 -96
- package/dist/events/predicate-parse.d.ts +23 -0
- package/dist/events/predicate-parse.js +68 -0
- package/dist/events/predicate.d.ts +5 -0
- package/dist/events/predicate.js +17 -3
- package/dist/events/ring-buffer.d.ts +8 -0
- package/dist/events/ring-buffer.js +35 -17
- 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/honesty.d.ts +11 -0
- package/dist/honesty/honesty.js +9 -1
- package/dist/honesty/verified.d.ts +10 -1
- package/dist/honesty/verified.js +42 -3
- package/dist/http-server.js +20 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +36 -3
- package/dist/init/agent-rules.d.ts +18 -0
- package/dist/init/agent-rules.js +28 -5
- package/dist/init/cra.d.ts +13 -0
- package/dist/init/cra.js +16 -0
- package/dist/init/detect.d.ts +14 -0
- package/dist/init/detect.js +23 -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/non-js-project.d.ts +27 -0
- package/dist/init/non-js-project.js +60 -0
- package/dist/init/plan-framework.d.ts +9 -0
- package/dist/init/plan-framework.js +55 -3
- package/dist/init/plan.d.ts +14 -0
- package/dist/init/plan.js +93 -12
- package/dist/init/run.d.ts +18 -1
- package/dist/init/run.js +169 -23
- package/dist/init/snippets.d.ts +20 -1
- package/dist/init/snippets.js +71 -6
- 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 +39 -9
- package/dist/mcp/mcp.js +64 -2
- package/dist/mcp/proxy-lifecycle.d.ts +19 -0
- package/dist/mcp/proxy-lifecycle.js +21 -0
- package/dist/pool/browser-pool.d.ts +9 -0
- package/dist/pool/browser-pool.js +38 -9
- package/dist/pool/playwright-launcher.js +10 -2
- package/dist/session/attachment-history.d.ts +46 -0
- package/dist/session/attachment-history.js +65 -0
- package/dist/session/live-control-tools.js +39 -2
- package/dist/session/live-control.d.ts +7 -9
- package/dist/session/live-control.js +34 -1
- package/dist/session/no-session-diagnosis.d.ts +11 -4
- package/dist/session/no-session-diagnosis.js +95 -14
- 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/review-store.d.ts +19 -0
- package/dist/session/review-store.js +34 -5
- package/dist/session/session-info.d.ts +17 -1
- package/dist/session/session-info.js +5 -1
- package/dist/session/session-manager.d.ts +12 -0
- package/dist/session/session-manager.js +83 -3
- package/dist/session/session.d.ts +9 -0
- package/dist/session/session.js +20 -0
- package/dist/status-payload.d.ts +13 -1
- package/dist/status-payload.js +6 -1
- package/dist/telemetry/app-instrumented.d.ts +37 -0
- package/dist/telemetry/app-instrumented.js +54 -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 +32 -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 +21 -1
- package/dist/telemetry/telemetry.js +34 -9
- package/dist/telemetry/verification-of.js +26 -1
- package/dist/tools/act-tools.js +63 -14
- package/dist/tools/assert-verdict.d.ts +7 -1
- package/dist/tools/assert-verdict.js +22 -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 +89 -8
- 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 +76 -7
- 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 +60 -5
- package/dist/tools/tool-kit.js +55 -24
- package/dist/tools/tool-surface.js +13 -0
- package/dist/tools/tools.js +22 -0
- package/dist/visual/capture-cleanup.d.ts +16 -0
- package/dist/visual/capture-cleanup.js +39 -0
- package/dist/visual/visual-tools.d.ts +18 -0
- package/dist/visual/visual-tools.js +24 -5
- package/docs/README.md +55 -0
- package/docs/agent-cheatsheet.md +15 -3
- 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 +15 -3
- package/docs/telemetry.md +16 -1
- package/docs/token-efficiency.md +2 -2
- package/package.json +2 -2
|
@@ -12,17 +12,9 @@ import { getSessionMetrics } from './session-metrics.js';
|
|
|
12
12
|
import { profileProject } from './project-profile.js';
|
|
13
13
|
import { startUpdateCheck } from '../update/update-nudge.js';
|
|
14
14
|
import { markDaemonStart } from './mcp-connection.js';
|
|
15
|
+
import { markInstrumentationClock } from './app-instrumented.js';
|
|
15
16
|
/** Let the daemon finish coming up before walking the source tree for a profile. */
|
|
16
17
|
const PROJECT_PROFILE_DELAY_MS = 5_000;
|
|
17
|
-
/**
|
|
18
|
-
* How often a long-lived daemon flushes its counters.
|
|
19
|
-
*
|
|
20
|
-
* ponytail: coarse on purpose. A daemon that is SIGKILLed — OOM, a closed laptop, `kill -9` — never
|
|
21
|
-
* reaches its shutdown handler, so without this the whole session's counters die with it. Thirty
|
|
22
|
-
* minutes bounds that loss to one window while keeping the event volume near zero for the common case
|
|
23
|
-
* (most daemons stop cleanly and send exactly one summary). If the lost windows ever show up as a
|
|
24
|
-
* real gap, the upgrade is a small on-disk journal replayed at the next start, not a shorter timer.
|
|
25
|
-
*/
|
|
26
18
|
/**
|
|
27
19
|
* How often a running daemon rolls up its window.
|
|
28
20
|
*
|
|
@@ -33,6 +25,24 @@ const PROJECT_PROFILE_DELAY_MS = 5_000;
|
|
|
33
25
|
* serve a tool.
|
|
34
26
|
*/
|
|
35
27
|
export const SESSION_FLUSH_MS = 5 * 60 * 1000;
|
|
28
|
+
/**
|
|
29
|
+
* When a session first rolls up, independent of the periodic interval.
|
|
30
|
+
*
|
|
31
|
+
* The interval alone leaves a hole exactly the width of itself: a daemon SIGKILLed before its first
|
|
32
|
+
* tick — a closed laptop, OOM, `kill -9`, a force-quit editor — reaches no shutdown handler and has
|
|
33
|
+
* emitted nothing, so the whole session is invisible.
|
|
34
|
+
*
|
|
35
|
+
* In the field `daemon_started` outran `daemon_stopped` by a wide margin: **a meaningful share of
|
|
36
|
+
* sessions never reported a summary at all.** Every "did anyone use Reticle" number is computed only
|
|
37
|
+
* on the sessions that did, and undercounts by an unknown amount, which is the one thing a funnel
|
|
38
|
+
* metric must not do.
|
|
39
|
+
*
|
|
40
|
+
* Ninety seconds is chosen against what it protects: a session that did real work usually does it
|
|
41
|
+
* early (snapshot -> act -> assert is seconds), so one early tick captures nearly all of it. It
|
|
42
|
+
* costs nothing on the daemons that dominate the population, because only a NON-EMPTY window emits
|
|
43
|
+
* and 74% of daemons never serve a tool at all.
|
|
44
|
+
*/
|
|
45
|
+
export const FIRST_FLUSH_MS = 90 * 1000;
|
|
36
46
|
/**
|
|
37
47
|
* Report the daemon as started, profile the project once, and keep the session counters flushing.
|
|
38
48
|
* Every timer is `unref`'d so telemetry can never be the reason a daemon refuses to exit.
|
|
@@ -44,6 +54,9 @@ export function installDaemonTelemetry(cwd, now = () => Date.now()) {
|
|
|
44
54
|
// this, so a published fix was invisible until someone manually ran `reticle update`.
|
|
45
55
|
startUpdateCheck(now);
|
|
46
56
|
markDaemonStart(now());
|
|
57
|
+
// Same clock, different half of the install: one measures how long before an AGENT attached, this
|
|
58
|
+
// one how long before an APP did. The gap between them is the funnel.
|
|
59
|
+
markInstrumentationClock(now());
|
|
47
60
|
// One profile per daemon start: what kind of project this is and how deeply Reticle is used in it.
|
|
48
61
|
// Deferred off the boot path — it walks the source tree, and nothing about a daemon coming up
|
|
49
62
|
// should wait on a filesystem scan.
|
|
@@ -57,7 +70,7 @@ export function installDaemonTelemetry(cwd, now = () => Date.now()) {
|
|
|
57
70
|
/* a profile is a nice-to-have; it must never touch the daemon */
|
|
58
71
|
}
|
|
59
72
|
}, PROJECT_PROFILE_DELAY_MS).unref();
|
|
60
|
-
const
|
|
73
|
+
const flushWindow = () => {
|
|
61
74
|
if (metrics.empty)
|
|
62
75
|
return; // an idle window is not worth an event
|
|
63
76
|
// NOT daemon_stopped: the daemon is still running. See SESSION_PROGRESS.
|
|
@@ -65,19 +78,25 @@ export function installDaemonTelemetry(cwd, now = () => Date.now()) {
|
|
|
65
78
|
session: metrics.summarize(false),
|
|
66
79
|
});
|
|
67
80
|
metrics.reset();
|
|
68
|
-
}
|
|
81
|
+
};
|
|
82
|
+
// The early one closes the hole the interval cannot: a session killed before its first tick had
|
|
83
|
+
// emitted nothing at all. See FIRST_FLUSH_MS.
|
|
84
|
+
const firstFlush = setTimeout(flushWindow, FIRST_FLUSH_MS);
|
|
85
|
+
firstFlush.unref();
|
|
86
|
+
const flush = setInterval(flushWindow, SESSION_FLUSH_MS);
|
|
69
87
|
flush.unref();
|
|
70
88
|
let stopped;
|
|
71
89
|
return {
|
|
72
|
-
shutdown() {
|
|
90
|
+
shutdown(exit) {
|
|
73
91
|
// Both SIGTERM and the idle-shutdown path can call this; the second caller awaits the first
|
|
74
92
|
// send rather than emitting a duplicate summary.
|
|
75
93
|
stopped ??= (async () => {
|
|
76
94
|
clearInterval(flush);
|
|
95
|
+
clearTimeout(firstFlush);
|
|
77
96
|
// The rich one: the whole session in a single event — duration, the tool histogram, error
|
|
78
97
|
// shapes, verifications, browser launches. This is what replaced the per-tool-call event.
|
|
79
98
|
await getTelemetry().emit(TelemetryEventKind.DAEMON_STOPPED, {
|
|
80
|
-
session: metrics.summarize(true),
|
|
99
|
+
session: metrics.summarize(true, exit),
|
|
81
100
|
});
|
|
82
101
|
})();
|
|
83
102
|
return stopped;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whose defect was that tool error?
|
|
3
|
+
*
|
|
4
|
+
* `toolErrors` was one number covering three failures with three different fixes, so the top-line
|
|
5
|
+
* count could be acted on only by unpacking `errors[]` by hand and reading the prose. Measured over
|
|
6
|
+
* 2026-08-10/11: 60 `state`, 22 `refusal`, 20 `schema` (serialized zod arrays) and 20 stale refs.
|
|
7
|
+
* Read as "126 tool errors" that hides the sixth of them where OUR schema failed to explain itself —
|
|
8
|
+
* which is the only bucket we can fix by writing better descriptions.
|
|
9
|
+
*
|
|
10
|
+
* Classified from the message SHAPE, never stored raw: this returns a bucket, and the caller keeps
|
|
11
|
+
* counts. The full message is already fingerprinted and skeletonised elsewhere.
|
|
12
|
+
*/
|
|
13
|
+
/** Whose defect. Ordered by which fix it implies. */
|
|
14
|
+
export declare const ErrorClass: {
|
|
15
|
+
/** Missing/unknown parameter, wrong type, unparseable predicate. **Our schema is unclear.** */
|
|
16
|
+
readonly SCHEMA: "schema";
|
|
17
|
+
/** No session, stale ref, disconnected. The world moved under the agent; not a schema problem. */
|
|
18
|
+
readonly STATE: "state";
|
|
19
|
+
/** We said no on purpose — a destructive block, an unsupported capability. Working as intended. */
|
|
20
|
+
readonly REFUSAL: "refusal";
|
|
21
|
+
/** Unrecognised. The bucket to watch: a large one is a blind spot, not a long tail. */
|
|
22
|
+
readonly OTHER: "other";
|
|
23
|
+
};
|
|
24
|
+
export type ErrorClass = (typeof ErrorClass)[keyof typeof ErrorClass];
|
|
25
|
+
/** Which bucket this message belongs to. Never throws — a classifier must not break a tool path. */
|
|
26
|
+
export declare function classifyError(message: string): ErrorClass;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whose defect was that tool error?
|
|
3
|
+
*
|
|
4
|
+
* `toolErrors` was one number covering three failures with three different fixes, so the top-line
|
|
5
|
+
* count could be acted on only by unpacking `errors[]` by hand and reading the prose. Measured over
|
|
6
|
+
* 2026-08-10/11: 60 `state`, 22 `refusal`, 20 `schema` (serialized zod arrays) and 20 stale refs.
|
|
7
|
+
* Read as "126 tool errors" that hides the sixth of them where OUR schema failed to explain itself —
|
|
8
|
+
* which is the only bucket we can fix by writing better descriptions.
|
|
9
|
+
*
|
|
10
|
+
* Classified from the message SHAPE, never stored raw: this returns a bucket, and the caller keeps
|
|
11
|
+
* counts. The full message is already fingerprinted and skeletonised elsewhere.
|
|
12
|
+
*/
|
|
13
|
+
/** Whose defect. Ordered by which fix it implies. */
|
|
14
|
+
export const ErrorClass = {
|
|
15
|
+
/** Missing/unknown parameter, wrong type, unparseable predicate. **Our schema is unclear.** */
|
|
16
|
+
SCHEMA: 'schema',
|
|
17
|
+
/** No session, stale ref, disconnected. The world moved under the agent; not a schema problem. */
|
|
18
|
+
STATE: 'state',
|
|
19
|
+
/** We said no on purpose — a destructive block, an unsupported capability. Working as intended. */
|
|
20
|
+
REFUSAL: 'refusal',
|
|
21
|
+
/** Unrecognised. The bucket to watch: a large one is a blind spot, not a long tail. */
|
|
22
|
+
OTHER: 'other',
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Patterns, most specific first.
|
|
26
|
+
*
|
|
27
|
+
* REFUSAL is tested before STATE because a destructive block often names a session too, and
|
|
28
|
+
* "we refused on purpose" is the more useful reading of that event.
|
|
29
|
+
*/
|
|
30
|
+
const PATTERNS = [
|
|
31
|
+
[/confirmdangerous|destructive action blocked/i, ErrorClass.REFUSAL],
|
|
32
|
+
[
|
|
33
|
+
/\brefus(e|ed|es|ing)\b|not supported|unsupported|cannot .* a <|contenteditable/i,
|
|
34
|
+
ErrorClass.REFUSAL,
|
|
35
|
+
],
|
|
36
|
+
[
|
|
37
|
+
/did not parse|unknown field|unrecognized_keys|missing required|requires a (string|number)|invalid parameter|expected .* received|is larger than/i,
|
|
38
|
+
ErrorClass.SCHEMA,
|
|
39
|
+
],
|
|
40
|
+
[
|
|
41
|
+
/no browser session connected|no connected session|multiple sessions connected|session disconnected|no longer resolves|no such session|not connected/i,
|
|
42
|
+
ErrorClass.STATE,
|
|
43
|
+
],
|
|
44
|
+
];
|
|
45
|
+
/** Which bucket this message belongs to. Never throws — a classifier must not break a tool path. */
|
|
46
|
+
export function classifyError(message) {
|
|
47
|
+
for (const [pattern, cls] of PATTERNS) {
|
|
48
|
+
if (pattern.test(message))
|
|
49
|
+
return cls;
|
|
50
|
+
}
|
|
51
|
+
return ErrorClass.OTHER;
|
|
52
|
+
}
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import { TelemetryAction } from '../cli/cli-parse.js';
|
|
2
2
|
/**
|
|
3
|
-
* `reticle feedback [--rating 1-5] [--bug] "what happened"` — the
|
|
3
|
+
* `reticle feedback [--rating 1-5] [--bug] "what happened"` — the feedback channel as a command.
|
|
4
4
|
*
|
|
5
5
|
* Kept to a single command with no prompts, no editor, and no account: the cost of telling us
|
|
6
6
|
* something has to be lower than the cost of shrugging and moving on, or we only ever hear from people
|
|
7
7
|
* angry enough to open an issue. A bare rating is a valid report; the words are optional garnish.
|
|
8
8
|
*
|
|
9
|
+
* `--agent --kind <…>` is the same channel for an AGENT that cannot reach `reticle_feedback` — during
|
|
10
|
+
* `init`, while instrumentation is half-wired, or when the daemon will not start. Those are the
|
|
11
|
+
* reports we are least likely to hear and most need: every other path we have requires the tool
|
|
12
|
+
* surface to already work, so a Reticle that fails before it is running fails silently by design.
|
|
13
|
+
*
|
|
9
14
|
* What is sent is PRINTED before it goes, every time. A channel that carries free text and does not
|
|
10
15
|
* show you the payload is asking for trust it has not earned.
|
|
11
16
|
*/
|
|
12
|
-
export declare function handleFeedback(text: string, rating: number | undefined, bug: boolean): Promise<void>;
|
|
17
|
+
export declare function handleFeedback(text: string, rating: number | undefined, bug: boolean, feedbackKind?: string, agent?: boolean): Promise<void>;
|
|
13
18
|
/**
|
|
14
19
|
* `reticle identify` — opt-in, and it shows the consent notice before it sends anything.
|
|
15
20
|
*
|
|
@@ -1,32 +1,43 @@
|
|
|
1
1
|
import { FeedbackKind, FeedbackSource } from '@reticlehq/core';
|
|
2
|
-
import { BUG_FLAG, RATING_FLAG } from '../cli/cli-parse.js';
|
|
2
|
+
import { AGENT_FLAG, BUG_FLAG, FEEDBACK_KINDS, KIND_FLAG, RATING_FLAG } from '../cli/cli-parse.js';
|
|
3
3
|
import { describeFeedbackPayload, submitFeedback } from './feedback.js';
|
|
4
4
|
import { describeTelemetry, setTelemetryEnabled } from './telemetry.js';
|
|
5
5
|
import { TelemetryAction } from '../cli/cli-parse.js';
|
|
6
6
|
import { clearIdentity, IDENTIFY_NOTICE, readIdentity, saveIdentity, submitIdentity, UsageContextKind, } from './identify.js';
|
|
7
7
|
/**
|
|
8
|
-
* `reticle feedback [--rating 1-5] [--bug] "what happened"` — the
|
|
8
|
+
* `reticle feedback [--rating 1-5] [--bug] "what happened"` — the feedback channel as a command.
|
|
9
9
|
*
|
|
10
10
|
* Kept to a single command with no prompts, no editor, and no account: the cost of telling us
|
|
11
11
|
* something has to be lower than the cost of shrugging and moving on, or we only ever hear from people
|
|
12
12
|
* angry enough to open an issue. A bare rating is a valid report; the words are optional garnish.
|
|
13
13
|
*
|
|
14
|
+
* `--agent --kind <…>` is the same channel for an AGENT that cannot reach `reticle_feedback` — during
|
|
15
|
+
* `init`, while instrumentation is half-wired, or when the daemon will not start. Those are the
|
|
16
|
+
* reports we are least likely to hear and most need: every other path we have requires the tool
|
|
17
|
+
* surface to already work, so a Reticle that fails before it is running fails silently by design.
|
|
18
|
+
*
|
|
14
19
|
* What is sent is PRINTED before it goes, every time. A channel that carries free text and does not
|
|
15
20
|
* show you the payload is asking for trust it has not earned.
|
|
16
21
|
*/
|
|
17
|
-
export async function handleFeedback(text, rating, bug) {
|
|
22
|
+
export async function handleFeedback(text, rating, bug, feedbackKind, agent = false) {
|
|
18
23
|
const line = (s) => {
|
|
19
24
|
process.stdout.write(`${s}\n`);
|
|
20
25
|
};
|
|
21
26
|
if ('' === text && rating === undefined) {
|
|
22
27
|
line(`usage: reticle feedback [${RATING_FLAG} 1-5] [${BUG_FLAG}] "what worked, what didn't"`);
|
|
28
|
+
line(` agents: reticle feedback ${AGENT_FLAG} ${KIND_FLAG} <${FEEDBACK_KINDS.join('|')}> "what happened"`);
|
|
23
29
|
line(' your words go to the maintainers; nothing from your app is ever included.');
|
|
24
30
|
process.exitCode = 1;
|
|
25
31
|
return;
|
|
26
32
|
}
|
|
27
33
|
const receipt = await submitFeedback({
|
|
28
|
-
|
|
29
|
-
|
|
34
|
+
// An agent filing through the CLI must not land in the HUMAN bucket: that bucket carries the
|
|
35
|
+
// ratings and the experience reports, and a few hundred agent reports mixed into it would make
|
|
36
|
+
// the one number that says whether people like Reticle mean nothing.
|
|
37
|
+
source: agent ? FeedbackSource.AGENT : FeedbackSource.HUMAN,
|
|
38
|
+
// `--kind` wins when given; `--bug` stays the shorthand it always was.
|
|
39
|
+
kind: feedbackKind ??
|
|
40
|
+
(bug ? FeedbackKind.BUG : FeedbackKind.EXPERIENCE),
|
|
30
41
|
// A rating with no words is a real report, but the schema wants text — say what the rating means.
|
|
31
42
|
text: '' === text ? `rated ${String(rating)}/5 with no comment` : text,
|
|
32
43
|
...(rating !== undefined ? { rating } : {}),
|
|
@@ -38,6 +49,39 @@ export async function handleFeedback(text, rating, bug) {
|
|
|
38
49
|
line(receipt.sent
|
|
39
50
|
? 'thanks your feedback is in. it genuinely changes what gets built next.'
|
|
40
51
|
: `not sent ${receipt.reason ?? 'unknown reason'}`);
|
|
52
|
+
for (const extra of identifyInvite(agent))
|
|
53
|
+
line(extra);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Offer a HUMAN a way to be replied to — printed after the receipt, never as a prompt.
|
|
57
|
+
*
|
|
58
|
+
* Feedback is anonymous by design and stays that way: the report has already been sent by the time
|
|
59
|
+
* this prints, so nothing here gates or delays it. This is an offer, not a question.
|
|
60
|
+
*
|
|
61
|
+
* It routes through `reticle identify`, which already does consented identity properly — it prints
|
|
62
|
+
* what it will send, states that identifying links this machine's prior anonymous history to that
|
|
63
|
+
* identity, and has `--forget`. Capturing an address inline on `feedback_submitted` instead would
|
|
64
|
+
* put PII on the anonymous event stream and break what `docs/telemetry.md` promises in public:
|
|
65
|
+
* "no domain sniffing, no email inference" and "no personal data is collected".
|
|
66
|
+
*
|
|
67
|
+
* Only for humans (an agent has no email), and only when nobody has identified yet — so somebody
|
|
68
|
+
* who declined is never asked twice.
|
|
69
|
+
*/
|
|
70
|
+
function identifyInvite(agent) {
|
|
71
|
+
if (agent)
|
|
72
|
+
return [];
|
|
73
|
+
try {
|
|
74
|
+
if (readIdentity() !== undefined)
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return []; // unreadable identity file: say nothing rather than risk asking twice
|
|
79
|
+
}
|
|
80
|
+
return [
|
|
81
|
+
'',
|
|
82
|
+
'want a reply? `reticle identify --context <company|side_project|oss|learning> --email you@example.com`',
|
|
83
|
+
' optional, and it tells you exactly what it sends before sending it.',
|
|
84
|
+
];
|
|
41
85
|
}
|
|
42
86
|
/**
|
|
43
87
|
* `reticle identify` — opt-in, and it shows the consent notice before it sends anything.
|
|
@@ -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
|
+
* In the field most sessions that made a tool call produced no verdict — and
|
|
77
|
+
* **almost all of those never called a verdict-producing tool even once.** They drove the app
|
|
78
|
+
* with `reticle_act` and never asked whether it worked. This counter already existed
|
|
79
|
+
* (`abandonedActions`); 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. */
|