@reticlehq/server 2.9.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.
Files changed (125) hide show
  1. package/SKILL.md +64 -26
  2. package/dist/capsule/capsule-store.d.ts +2 -2
  3. package/dist/cli/cli-parse.d.ts +1 -1
  4. package/dist/cli/cli-parse.js +1 -1
  5. package/dist/cli/cli-verify.js +1 -1
  6. package/dist/crawl/crawl.d.ts +7 -2
  7. package/dist/crawl/crawl.js +52 -9
  8. package/dist/dev/stale-issue-guard.d.ts +43 -0
  9. package/dist/dev/stale-issue-guard.js +75 -0
  10. package/dist/domain/domain-tools.js +3 -2
  11. package/dist/ee/audit-log.js +2 -2
  12. package/dist/events/body-failures.d.ts +2 -2
  13. package/dist/events/contradiction-folds.d.ts +45 -0
  14. package/dist/events/contradiction-folds.js +42 -0
  15. package/dist/events/contradictions.d.ts +19 -1
  16. package/dist/events/contradictions.js +17 -2
  17. package/dist/events/echo-mismatch.d.ts +2 -2
  18. package/dist/events/predicate-asks.d.ts +17 -0
  19. package/dist/events/predicate-asks.js +26 -0
  20. package/dist/events/predicate-eval.js +211 -24
  21. package/dist/events/predicate-parse.js +9 -3
  22. package/dist/events/predicate-schema.d.ts +18 -0
  23. package/dist/events/predicate-schema.js +55 -0
  24. package/dist/events/stale-response.d.ts +2 -3
  25. package/dist/events/stale-response.js +49 -2
  26. package/dist/events/unit-mismatch.d.ts +2 -2
  27. package/dist/flows/flow-tools.js +34 -5
  28. package/dist/flows/predicate-to-expect.js +8 -0
  29. package/dist/flows/verify-change-tools.js +6 -2
  30. package/dist/honesty/blind-spots.d.ts +9 -0
  31. package/dist/honesty/blind-spots.js +17 -4
  32. package/dist/honesty/gap-ledger.d.ts +52 -0
  33. package/dist/honesty/gap-ledger.js +63 -0
  34. package/dist/honesty/instrumentation-gaps.d.ts +55 -0
  35. package/dist/honesty/instrumentation-gaps.js +21 -0
  36. package/dist/honesty/observability.d.ts +37 -0
  37. package/dist/honesty/observability.js +40 -0
  38. package/dist/honesty/verified.js +16 -0
  39. package/dist/index.d.ts +18 -1
  40. package/dist/index.js +57 -0
  41. package/dist/init/mcp.d.ts +1 -2
  42. package/dist/init/mcp.js +4 -1
  43. package/dist/init/node-io.js +3 -0
  44. package/dist/init/run.d.ts +18 -0
  45. package/dist/init/run.js +34 -3
  46. package/dist/init/slash-command.d.ts +1 -1
  47. package/dist/init/slash-command.js +33 -10
  48. package/dist/journal/envelope.d.ts +24 -24
  49. package/dist/license/license.d.ts +76 -5
  50. package/dist/license/license.js +93 -29
  51. package/dist/mcp/mcp-proxy.d.ts +2 -27
  52. package/dist/mcp/mcp-proxy.js +4 -88
  53. package/dist/mcp/mcp.d.ts +18 -2
  54. package/dist/mcp/mcp.js +31 -10
  55. package/dist/mcp/proxy-daemon-probe.d.ts +8 -0
  56. package/dist/mcp/proxy-daemon-probe.js +48 -0
  57. package/dist/mcp/proxy-handshake.js +1 -1
  58. package/dist/mcp/server-instructions.js +2 -2
  59. package/dist/mcp/sse-frame-parser.d.ts +19 -0
  60. package/dist/mcp/sse-frame-parser.js +41 -0
  61. package/dist/project/artifact-root.d.ts +83 -0
  62. package/dist/project/artifact-root.js +112 -0
  63. package/dist/project/memory-fs.d.ts +9 -0
  64. package/dist/project/memory-fs.js +88 -0
  65. package/dist/project/project-store.d.ts +17 -0
  66. package/dist/project/project-store.js +33 -0
  67. package/dist/project/remember-project.d.ts +24 -0
  68. package/dist/project/remember-project.js +40 -0
  69. package/dist/project/session-root.d.ts +15 -0
  70. package/dist/project/session-root.js +31 -0
  71. package/dist/runs/run-tools.js +2 -1
  72. package/dist/session/lease-availability.d.ts +40 -0
  73. package/dist/session/lease-availability.js +43 -0
  74. package/dist/session/no-session-diagnosis.d.ts +17 -0
  75. package/dist/session/no-session-diagnosis.js +41 -8
  76. package/dist/session/no-session-watch.js +7 -0
  77. package/dist/session/session-readiness.js +1 -1
  78. package/dist/session/session.d.ts +9 -0
  79. package/dist/session/session.js +9 -0
  80. package/dist/session/stall-clock.d.ts +18 -0
  81. package/dist/session/stall-clock.js +48 -0
  82. package/dist/telemetry/daemon-telemetry.js +16 -22
  83. package/dist/telemetry/license-activation.d.ts +48 -0
  84. package/dist/telemetry/license-activation.js +57 -0
  85. package/dist/telemetry/session-metrics.d.ts +1 -1
  86. package/dist/telemetry/session-metrics.js +1 -1
  87. package/dist/telemetry/telemetry.d.ts +12 -3
  88. package/dist/telemetry/telemetry.js +34 -5
  89. package/dist/tools/act-tools.js +34 -4
  90. package/dist/tools/assert-verdict.d.ts +2 -0
  91. package/dist/tools/assert-verdict.js +24 -1
  92. package/dist/tools/contract-tools.js +5 -3
  93. package/dist/tools/coverage-tools.js +33 -0
  94. package/dist/tools/dynamic-tools.js +1 -1
  95. package/dist/tools/error-recovery.js +1 -1
  96. package/dist/tools/feedback-tools.js +31 -6
  97. package/dist/tools/invoke-tool.d.ts +1 -1
  98. package/dist/tools/invoke-tool.js +10 -5
  99. package/dist/tools/observe-tools.js +8 -2
  100. package/dist/tools/tool-kit.d.ts +46 -4
  101. package/dist/tools/tool-names.d.ts +2 -0
  102. package/dist/tools/tool-names.js +2 -0
  103. package/dist/tools/tool-surface.js +2 -3
  104. package/dist/tools/tools.js +16 -1
  105. package/docs/README.md +1 -0
  106. package/docs/agent-cheatsheet.md +2 -2
  107. package/docs/capabilities.mdx +4 -4
  108. package/docs/cli/hunt.mdx +2 -2
  109. package/docs/deploy-checks.md +2 -2
  110. package/docs/docs.json +2 -2
  111. package/docs/enterprise.md +9 -3
  112. package/docs/flows.md +4 -4
  113. package/docs/gates.md +1 -1
  114. package/docs/license-activation.mdx +145 -0
  115. package/docs/recipes.mdx +2 -2
  116. package/docs/telemetry-contract.md +18 -12
  117. package/docs/telemetry-events.mdx +1 -34
  118. package/docs/telemetry.md +0 -1
  119. package/docs/tools-overview.mdx +1 -1
  120. package/docs/tools-session-and-feedback.mdx +5 -0
  121. package/docs/tools-tools-and-run.mdx +15 -15
  122. package/docs/usage.md +3 -3
  123. package/package.json +2 -2
  124. package/dist/telemetry/instrumentation-stall.d.ts +0 -50
  125. package/dist/telemetry/instrumentation-stall.js +0 -90
@@ -18,7 +18,9 @@
18
18
  * Pure: everything it needs is passed in, so the probe that discovers listening ports stays out of
19
19
  * the hot resolve() path and this stays unit-testable.
20
20
  */
21
+ import { leaseCaveat } from './lease-availability.js';
21
22
  import { DEV_SERVER_PORTS } from '../cli/cli-port.js';
23
+ import { STALL_AFTER_MS } from './stall-clock.js';
22
24
  /** The one framework whose most likely cause differs from every other framework's. */
23
25
  const NUXT = 'nuxt';
24
26
  /**
@@ -57,6 +59,17 @@ const RETRY = SELF_RECOVERING_MARKER;
57
59
  const URL_THEN_LEASE = 'Once you have that URL, open it with reticle_lease {action:"acquire", url} rather than a shell ' +
58
60
  'command — it needs no CLI on PATH (reach it with reticle_run {tool:"reticle_lease"} if it is ' +
59
61
  'not advertised directly).';
62
+ /**
63
+ * The lease advice, with the reason it cannot be taken when that is the case.
64
+ *
65
+ * A function rather than a constant so the caveat cannot be forgotten at one of the six sites that
66
+ * offer the lease — which is exactly how the offer came to be made by the command that had already
67
+ * diagnosed the problem.
68
+ */
69
+ function leaseAdvice(base, facts) {
70
+ const caveat = leaseCaveat(facts.leaseBrowser);
71
+ return caveat === undefined ? base : `${base} ${caveat}`;
72
+ }
60
73
  const SELF_SERVE = 'You do not have to wait for the human: reticle_lease {action:"acquire", url} opens a browser ' +
61
74
  'Reticle drives itself, and returns a sessionId you can use immediately (reach it with ' +
62
75
  'reticle_run {tool:"reticle_lease"} if it is not advertised directly; release it when you finish).';
@@ -248,6 +261,26 @@ function searchedClause(facts) {
248
261
  return '';
249
262
  return ` I looked in: ${searched.join(', ')}. If your app is not in that list, that is the answer — this daemon is standing somewhere else.`;
250
263
  }
264
+ /**
265
+ * A leading sentence when the daemon has been up long enough that no app is coming.
266
+ *
267
+ * Only fires for a WIRED project (initialized) that has NEVER connected and is past the
268
+ * threshold. An unwired project already names the install gap as its primary cause; adding a time
269
+ * sentence there would double-blame.
270
+ */
271
+ function stallClause(facts) {
272
+ const { daemonUpMs, initialized, everConnected } = facts;
273
+ if (everConnected)
274
+ return '';
275
+ if (!initialized)
276
+ return '';
277
+ if (daemonUpMs === undefined || daemonUpMs < STALL_AFTER_MS)
278
+ return '';
279
+ const minutes = Math.round(daemonUpMs / 60_000);
280
+ return (`This daemon has been running for ${String(minutes)} minutes and no app has connected. ` +
281
+ 'The most likely cause is that the dev server was not restarted after adding the Reticle ' +
282
+ 'plugin, so the running bundle carries no SDK. Restart the dev server first. ');
283
+ }
251
284
  export function diagnoseNoSession(facts) {
252
285
  const { everConnected, initialized, listening, port } = facts;
253
286
  // Named when known: a claim about a missing file is a claim about ONE directory.
@@ -271,7 +304,7 @@ export function diagnoseNoSession(facts) {
271
304
  }
272
305
  return ('no browser session connected, but one WAS connected to this daemon earlier, so the wiring ' +
273
306
  'is correct. The tab was closed, navigated away, or hard-reloaded. Ask the human to reopen ' +
274
- `the app (or run ${OPEN_CMD_BARE}), or reload the tab. ${SELF_SERVE} ${RETRY}`);
307
+ `the app (or run ${OPEN_CMD_BARE}), or reload the tab. ${leaseAdvice(SELF_SERVE, facts)} ${RETRY}`);
275
308
  }
276
309
  // Ranked ahead of every "we have never seen one" branch below, because it is the one fact that
277
310
  // OUTRANKS them: those branches all reason from an absence, and this reasons from a recorded
@@ -282,7 +315,7 @@ export function diagnoseNoSession(facts) {
282
315
  ? `${unattributedListeners(listening)} If the dev server is not running, start it first ` +
283
316
  '(the command is in `next_action`).'
284
317
  : unattributedListeners(listening);
285
- return `${RESTARTED_LEAD} ${OPEN_THE_APP} ${listeners} ${rankedCauses(facts)} ${SELF_SERVE} ${RETRY}`;
318
+ return `${RESTARTED_LEAD} ${OPEN_THE_APP} ${listeners} ${rankedCauses(facts)} ${leaseAdvice(SELF_SERVE, facts)} ${RETRY}`;
286
319
  }
287
320
  // A config found elsewhere outranks every "you may have no SDK" branch below: those reason from
288
321
  // an absence in ONE directory, and this is a file we read in another. Ranked here so the reader is
@@ -290,7 +323,7 @@ export function diagnoseNoSession(facts) {
290
323
  if (!initialized && (facts.configsElsewhere ?? []).length > 0) {
291
324
  return ('no browser session connected, and this daemon has never seen one. ' +
292
325
  `${configsElsewhereClause(facts)} ${unattributedListeners(listening)} ${OPEN_THE_APP} ` +
293
- `${rankedCauses(facts)} ${SELF_SERVE} ${RETRY}`);
326
+ `${rankedCauses(facts)} ${leaseAdvice(SELF_SERVE, facts)} ${RETRY}`);
294
327
  }
295
328
  if (0 === listening.length) {
296
329
  // Lead with the stronger EVIDENCE, and do not overstate what it is.
@@ -320,9 +353,9 @@ export function diagnoseNoSession(facts) {
320
353
  `file ${INIT_CMD} writes, so the app may carry no Reticle SDK — but check the app's ` +
321
354
  'OWN directory before re-running `init`: in a monorepo the daemon often runs at the root ' +
322
355
  'while the app lives in a subdirectory, and an app wired by the Vite or Babel plugin ' +
323
- `carries the SDK without that file at all.${searchedClause(facts)} ${URL_THEN_LEASE} ${RETRY}`);
356
+ `carries the SDK without that file at all.${searchedClause(facts)} ${leaseAdvice(URL_THEN_LEASE, facts)} ${RETRY}`);
324
357
  }
325
- return (`no browser session connected, and this daemon has never seen one. ${OPEN_THE_APP} ` +
358
+ return (`${stallClause(facts)}no browser session connected, and this daemon has never seen one. ${OPEN_THE_APP} ` +
326
359
  'Nothing is listening on the ports Reticle scans ' +
327
360
  `(${SCANNED_PORTS}) either, and the most common reason for that is a dev server that is not ` +
328
361
  'running: start it yourself in the background with the command in `next_action` — it is read ' +
@@ -338,7 +371,7 @@ export function diagnoseNoSession(facts) {
338
371
  // Deliberately NOT offering reticle_lease here, and a test pins that: a lease opens a URL, and
339
372
  // if nothing is listening there is nothing at any URL to open. Asking for the real one is the
340
373
  // only move that can recover the :7699 case.
341
- `the app IS running, ask the human for its URL rather than assuming it is down. ${URL_THEN_LEASE} ` +
374
+ `the app IS running, ask the human for its URL rather than assuming it is down. ${leaseAdvice(URL_THEN_LEASE, facts)} ` +
342
375
  // Reachable only for a project that HAS been through `init` — the uninstrumented case is
343
376
  // answered above, leading with that certainty instead of behind this scan's guess.
344
377
  `${RETRY}`);
@@ -353,8 +386,8 @@ export function diagnoseNoSession(facts) {
353
386
  `has one, the app is wired and simply has no page open — ${OPEN_CMD}. ` +
354
387
  `${unattributedListeners(listening)}${searchedClause(facts)} ${RETRY}`);
355
388
  }
356
- return ('no browser session connected, and this daemon has never seen one for this project, which is ' +
389
+ return (`${stallClause(facts)}no browser session connected, and this daemon has never seen one for this project, which is ` +
357
390
  `wired for Reticle. ${OPEN_THE_APP} ${unattributedListeners(listening)} ` +
358
391
  'If the page IS open and still does not appear, the app is wired and the SDK is not reaching ' +
359
- `this daemon (on ${String(port)}). ${rankedCauses(facts)} ${SELF_SERVE} ${RETRY}`);
392
+ `this daemon (on ${String(port)}). ${rankedCauses(facts)} ${leaseAdvice(SELF_SERVE, facts)} ${RETRY}`);
360
393
  }
@@ -17,6 +17,7 @@ import { readProjectFramework, readProjectId, readProjectPort } from '../cli/cli
17
17
  import { discoverProjectConfigs } from '../cli/config-discovery.js';
18
18
  import { hasProjectConnectedBefore, rememberConnected } from './connection-memory.js';
19
19
  import { reticleStateHome } from '../daemon/daemon.js';
20
+ import { stallUptime } from './stall-clock.js';
20
21
  /** Slow enough to be free, fast enough that a dev server started 15s ago is already reflected. */
21
22
  const REFRESH_MS = 15_000;
22
23
  /** The URL an auto-attach opens. `localhost` for the same reason the probe uses it: both stacks. */
@@ -193,6 +194,12 @@ export function startNoSessionWatch(options) {
193
194
  return framework === undefined ? {} : { framework };
194
195
  })(),
195
196
  leaseExpired: (options.reapedLeases?.() ?? 0) > 0,
197
+ // How long this daemon has been waiting with no app. The diagnosis uses it to surface
198
+ // "install never finished" — the same condition telemetry already knows about.
199
+ ...(() => {
200
+ const upMs = stallUptime(Date.now());
201
+ return upMs === undefined ? {} : { daemonUpMs: upMs };
202
+ })(),
196
203
  // Read here rather than at boot: `.reticle.json` can be written by `init` after this daemon
197
204
  // started, which is the ordinary first-install order, and a port cached from before it existed
198
205
  // would make the daemon confidently report no mismatch on the one run where there is one.
@@ -11,7 +11,7 @@ const DEFAULT_POLL_MS = 25;
11
11
  */
12
12
  export const RETICLE_LOOP_GUIDE = 'Reticle loop — LOOK: reticle_snapshot / reticle_query / reticle_inspect · ACT: reticle_act (or reticle_act_and_wait) · ' +
13
13
  'OBSERVE: reticle_observe / reticle_wait_for / reticle_network / reticle_console · ASSERT: reticle_assert over program ' +
14
- 'truth, not just the DOM · REGRESS: reticle_record{action:"start"} → reticle_replay, or reticle_flow_verify for the whole ' +
14
+ 'truth, not just the DOM · REGRESS: reticle_record{action:"start"} → reticle_replay, or reticle_verify{action:"flows"} for the whole ' +
15
15
  'suite. The human can flag bugs from the panel — drain them with reticle_session{action:"review"} and resolve each once fixed. ' +
16
16
  'MANDATORY: the moment you stop driving — finishing your reply or waiting on the human — call reticle_session {action:"yield"} ' +
17
17
  '(mode:"waiting", or mode:"ask" with the question) so the panel shows your real state; reticle_session{action:"end"} ' +
@@ -1,6 +1,7 @@
1
1
  import type { WebSocket } from 'ws';
2
2
  import type { ImpactSnapshot } from '@reticlehq/core';
3
3
  import { LastAct } from './last-act.js';
4
+ import { GapLedger } from '../honesty/gap-ledger.js';
4
5
  import { type PageRuntime } from './command-timeout.js';
5
6
  import { type SessionHealth } from './session-health.js';
6
7
  export type { SessionHealth };
@@ -111,6 +112,14 @@ export declare class Session {
111
112
  * from it, and the split between those two tool calls is the whole reason it exists.
112
113
  */
113
114
  readonly lastAct: LastAct;
115
+ /**
116
+ * What this app still cannot tell Reticle, as of the most recent verdict.
117
+ *
118
+ * Lives on the session because that is the scope the question belongs to: "am I done with this
119
+ * app" is a session question, and a gap closed halfway through must not follow the agent to the
120
+ * end of it.
121
+ */
122
+ readonly gaps: GapLedger;
114
123
  /**
115
124
  * Stamp the wall-clock of the latest AGENT command (called whenever a tool resolves this session).
116
125
  * If the reaper had auto-ended the session, a fresh command means the agent is alive again, so the
@@ -1,5 +1,6 @@
1
1
  import { recordImpact } from '../impact/impact-recorder.js';
2
2
  import { LastAct } from './last-act.js';
3
+ import { GapLedger } from '../honesty/gap-ledger.js';
3
4
  import { commandTimeoutMessage } from './command-timeout.js';
4
5
  import { readHealthEvent } from './session-health.js';
5
6
  /** The HUD does not need 200 frames to watch a counter climb; the last state always lands. */
@@ -370,6 +371,14 @@ export class Session {
370
371
  * from it, and the split between those two tool calls is the whole reason it exists.
371
372
  */
372
373
  lastAct = new LastAct();
374
+ /**
375
+ * What this app still cannot tell Reticle, as of the most recent verdict.
376
+ *
377
+ * Lives on the session because that is the scope the question belongs to: "am I done with this
378
+ * app" is a session question, and a gap closed halfway through must not follow the agent to the
379
+ * end of it.
380
+ */
381
+ gaps = new GapLedger();
373
382
  // ── Server-authoritative liveness (immune to browser-tab throttling) ──────────────
374
383
  /**
375
384
  * Stamp the wall-clock of the latest AGENT command (called whenever a tool resolves this session).
@@ -0,0 +1,18 @@
1
+ /**
2
+ * When a run with nothing connected is worth mentioning to a human.
3
+ *
4
+ * Ten minutes sits well past setup latency — reading a doc, restarting a dev server, opening a tab —
5
+ * and short enough that the warning still arrives while somebody is at the keyboard.
6
+ */
7
+ export declare const STALL_AFTER_MS: number;
8
+ /** Start the clock, from the same place the instrumentation clock is marked. */
9
+ export declare function markStallClock(now: number): void;
10
+ /**
11
+ * How long this daemon has waited with no app, or undefined if the question is moot.
12
+ *
13
+ * Moot means the clock was never started, or an app already connected. A run that starts slowly and
14
+ * then works has nothing to report, which is why this returns undefined rather than a duration.
15
+ */
16
+ export declare function stallUptime(now: number): number | undefined;
17
+ /** Tests only. */
18
+ export declare function resetStallClock(): void;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * How long this daemon has waited with no app connected.
3
+ *
4
+ * This is what survived `instrumentation_stalled`. The EVENT was removed: it claimed to separate
5
+ * "never wired" from "died before reporting" and did not, because it only fired on a flush tick or a
6
+ * graceful shutdown, so a killed daemon was invisible to it exactly as before. Everything it carried
7
+ * is derivable from `daemon_started` minus `app_instrumented`, joined on `sessionId` — and that set
8
+ * difference is strictly MORE complete, because it does catch the killed daemons. A second way to
9
+ * compute the same number that disagrees with the first is worse than not having it.
10
+ *
11
+ * The CLOCK is a different thing and stays. Two live features read it, neither of them a metric: the
12
+ * no-session diagnosis tells an agent how long this daemon has been waiting, and the daemon warns on
13
+ * stderr when a run passes the threshold with nothing connected. Both are help for somebody stuck
14
+ * mid-install, which is the moment they are least able to work it out themselves.
15
+ *
16
+ * It lives under `session/` rather than `telemetry/` now, beside the two files that read it. The old
17
+ * home is why the removal looked like it would take the diagnosis with it.
18
+ */
19
+ import { appEverConnected } from '../telemetry/app-instrumented.js';
20
+ /**
21
+ * When a run with nothing connected is worth mentioning to a human.
22
+ *
23
+ * Ten minutes sits well past setup latency — reading a doc, restarting a dev server, opening a tab —
24
+ * and short enough that the warning still arrives while somebody is at the keyboard.
25
+ */
26
+ export const STALL_AFTER_MS = 10 * 60 * 1000;
27
+ let daemonStartedAt;
28
+ /** Start the clock, from the same place the instrumentation clock is marked. */
29
+ export function markStallClock(now) {
30
+ daemonStartedAt = now;
31
+ }
32
+ /**
33
+ * How long this daemon has waited with no app, or undefined if the question is moot.
34
+ *
35
+ * Moot means the clock was never started, or an app already connected. A run that starts slowly and
36
+ * then works has nothing to report, which is why this returns undefined rather than a duration.
37
+ */
38
+ export function stallUptime(now) {
39
+ if (daemonStartedAt === undefined)
40
+ return undefined;
41
+ if (appEverConnected())
42
+ return undefined;
43
+ return now - daemonStartedAt;
44
+ }
45
+ /** Tests only. */
46
+ export function resetStallClock() {
47
+ daemonStartedAt = undefined;
48
+ }
@@ -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, reportInstrumentationStall } from './instrumentation-stall.js';
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,18 +73,22 @@ 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 is the whole point: a stalled install produces an
79
- // empty window by definition — no app, so no tool calls, so nothing to roll up. Checking it
80
- // after the early return would make the one case this event exists for the one case it never
81
- // sees. It is self-limiting (once per run, never after an app connects), so running it on every
82
- // tick costs a comparison.
83
- reportInstrumentationStall({
84
- // Same test `app_instrumented` uses for the same field, so the success and failure events
85
- // stay directly comparable: a stamped projectId means `init` has run here.
86
- initialized: readProjectId(cwd) !== undefined,
87
- agentAttached: metrics.agentEverAttached,
88
- }, now);
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;
88
+ const minutes = Math.round(STALL_AFTER_MS / 60_000);
89
+ process.stderr.write(`[reticle] no app has connected after ${String(minutes)} minutes — ` +
90
+ 'the install may be incomplete. Restart the dev server if you added the Reticle plugin.\n');
91
+ }
89
92
  if (metrics.empty)
90
93
  return; // an idle window is not worth an event
91
94
  // NOT daemon_stopped: the daemon is still running. See SESSION_PROGRESS.
@@ -108,15 +111,6 @@ export function installDaemonTelemetry(cwd, now = () => Date.now()) {
108
111
  stopped ??= (async () => {
109
112
  clearInterval(flush);
110
113
  clearTimeout(firstFlush);
111
- // Last chance to say that no app ever arrived. The periodic path cannot report an
112
- // UNATTENDED stall at all — that daemon idle-exits at five minutes and the periodic
113
- // threshold is ten — so without this the event only ever describes daemons with an agent
114
- // attached, which is not the population it was built to measure. Self-limiting: if the
115
- // interval already reported, this returns false and emits nothing.
116
- reportInstrumentationStall({
117
- initialized: readProjectId(cwd) !== undefined,
118
- agentAttached: metrics.agentEverAttached,
119
- }, now, { atShutdown: true });
120
114
  // The rich one: the whole session in a single event — duration, the tool histogram, error
121
115
  // shapes, verifications, browser launches. This is what replaced the per-tool-call event.
122
116
  await getTelemetry().emit(TelemetryEventKind.DAEMON_STOPPED, {
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Enterprise activation, as the three facts telemetry reports on every event.
3
+ *
4
+ * WHY IT IS ON EVERY EVENT, not just an activation one: the questions a licensed customer generates
5
+ * are "how much is this org using it", "what is breaking for them", "did their key lapse" — and every
6
+ * one of those is answered by an event that has nothing to do with licensing. A status that rode only
7
+ * its own event would tell us a key verified once and nothing about the sessions it covered. Same
8
+ * reasoning that moved `installSource` onto every event.
9
+ *
10
+ * WHY STATUS IS SEPARATE FROM IDENTITY: `licenseId` is only present while a key verifies, so on
11
+ * identity alone a lapsed customer and a departed one are the same silence. `licenseStatus` keeps
12
+ * reporting through `expired` and `invalid`, which is the difference between seeing a renewal coming
13
+ * and hearing about it from the customer.
14
+ *
15
+ * WHAT IS DELIBERATELY ABSENT: the organisation NAME, and the PLAN. The name is free text somebody
16
+ * typed at signing time, which rule 3 forbids outright. The plan is merely redundant, which is a
17
+ * quieter reason to leave something out and an easy one to lose sight of: it is keyed by this same
18
+ * `lid` in the issuance ledger, so sending it on every event from every machine forever pays a
19
+ * per-event cost to carry what one local file already holds, and the join that turns a `lid` into a
20
+ * company turns it into a plan at the same moment. `reticle license` still reports it locally, where
21
+ * it costs nothing and answers a question the operator is actually asking.
22
+ *
23
+ * More on the name: It is free text somebody typed at signing time,
24
+ * and rule 3 of the telemetry contract is names-never-values. `licenseId` is an opaque uuid that
25
+ * resolves to a company only against the issuance ledger held locally, so the analytics backend never
26
+ * holds a customer list.
27
+ */
28
+ import { LicenseActivation } from '@reticlehq/core';
29
+ /** The activation facts that ride the wire. All absent on a build with no issuer key baked. */
30
+ export interface LicenseFacts {
31
+ licenseId?: string;
32
+ licenseStatus?: LicenseActivation;
33
+ }
34
+ /**
35
+ * Resolve activation for one event. The clock is passed in (rule 7) and is the EVENT's clock, not a
36
+ * value captured at startup: sessions here run to eleven hours, so a key that expires mid-session must
37
+ * start reporting `expired` from the event it expired on rather than the whole run inheriting the
38
+ * status it had at boot.
39
+ *
40
+ * `eval` reports NOTHING. It means no issuer key is baked at all, which is every OSS install and every
41
+ * source checkout, so emitting it would add three properties to the dominant population to say "not a
42
+ * licensed build" — which absence already says, more cheaply. A mis-built production release resolves
43
+ * to `invalid` rather than `eval` and IS reported, because that one we need to hear about.
44
+ *
45
+ * Never throws. A telemetry property may not change behaviour (rule 5), and this one sits in front of
46
+ * key parsing, which is the part most likely to be handed something malformed.
47
+ */
48
+ export declare function licenseFacts(now: number, env?: NodeJS.ProcessEnv): LicenseFacts;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Enterprise activation, as the three facts telemetry reports on every event.
3
+ *
4
+ * WHY IT IS ON EVERY EVENT, not just an activation one: the questions a licensed customer generates
5
+ * are "how much is this org using it", "what is breaking for them", "did their key lapse" — and every
6
+ * one of those is answered by an event that has nothing to do with licensing. A status that rode only
7
+ * its own event would tell us a key verified once and nothing about the sessions it covered. Same
8
+ * reasoning that moved `installSource` onto every event.
9
+ *
10
+ * WHY STATUS IS SEPARATE FROM IDENTITY: `licenseId` is only present while a key verifies, so on
11
+ * identity alone a lapsed customer and a departed one are the same silence. `licenseStatus` keeps
12
+ * reporting through `expired` and `invalid`, which is the difference between seeing a renewal coming
13
+ * and hearing about it from the customer.
14
+ *
15
+ * WHAT IS DELIBERATELY ABSENT: the organisation NAME, and the PLAN. The name is free text somebody
16
+ * typed at signing time, which rule 3 forbids outright. The plan is merely redundant, which is a
17
+ * quieter reason to leave something out and an easy one to lose sight of: it is keyed by this same
18
+ * `lid` in the issuance ledger, so sending it on every event from every machine forever pays a
19
+ * per-event cost to carry what one local file already holds, and the join that turns a `lid` into a
20
+ * company turns it into a plan at the same moment. `reticle license` still reports it locally, where
21
+ * it costs nothing and answers a question the operator is actually asking.
22
+ *
23
+ * More on the name: It is free text somebody typed at signing time,
24
+ * and rule 3 of the telemetry contract is names-never-values. `licenseId` is an opaque uuid that
25
+ * resolves to a company only against the issuance ledger held locally, so the analytics backend never
26
+ * holds a customer list.
27
+ */
28
+ import { LicenseActivation } from '@reticlehq/core';
29
+ import { describeLicense } from '../license/license.js';
30
+ /**
31
+ * Resolve activation for one event. The clock is passed in (rule 7) and is the EVENT's clock, not a
32
+ * value captured at startup: sessions here run to eleven hours, so a key that expires mid-session must
33
+ * start reporting `expired` from the event it expired on rather than the whole run inheriting the
34
+ * status it had at boot.
35
+ *
36
+ * `eval` reports NOTHING. It means no issuer key is baked at all, which is every OSS install and every
37
+ * source checkout, so emitting it would add three properties to the dominant population to say "not a
38
+ * licensed build" — which absence already says, more cheaply. A mis-built production release resolves
39
+ * to `invalid` rather than `eval` and IS reported, because that one we need to hear about.
40
+ *
41
+ * Never throws. A telemetry property may not change behaviour (rule 5), and this one sits in front of
42
+ * key parsing, which is the part most likely to be handed something malformed.
43
+ */
44
+ export function licenseFacts(now, env = process.env) {
45
+ try {
46
+ const report = describeLicense(now, env);
47
+ if (LicenseActivation.EVAL === report.status)
48
+ return {};
49
+ return {
50
+ licenseStatus: report.status,
51
+ ...(report.licenseId !== undefined ? { licenseId: report.licenseId } : {}),
52
+ };
53
+ }
54
+ catch {
55
+ return {};
56
+ }
57
+ }
@@ -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 instrumentation-stall check, which needs to tell two very different silences apart:
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 instrumentation-stall check, which needs to tell two very different silences apart:
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 InstrumentationStall, 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 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.
@@ -71,6 +82,7 @@ const DETACHED_SEND_SCRIPT = "fetch(process.argv[1],{method:'POST',headers:{'con
71
82
  /** Env var names — mirror cloud-sync's `RETICLE_*` convention. */
72
83
  import { isReticleSourceCheckout } from './dev-repo.js';
73
84
  import { projectInstallSource } from './install-source.js';
85
+ import { licenseFacts } from './license-activation.js';
74
86
  import { gitFacts } from './git-facts.js';
75
87
  import { currentAutomationHint } from './automation-hint.js';
76
88
  const Env = {
@@ -302,13 +314,16 @@ export const createTelemetry = (opts) => {
302
314
  // feedback, and no metric is worth phoning home from a contributor's clone.
303
315
  if (feedbackOnly && TelemetryEventKind.FEEDBACK_SUBMITTED !== kind)
304
316
  return false;
317
+ // One clock read for the whole event: `ts` and the licence check must agree, and two calls to
318
+ // now() can straddle an expiry.
319
+ const eventTs = now();
305
320
  const event = {
306
321
  v: TELEMETRY_EVENT_VERSION,
307
322
  anonymousId,
308
323
  sessionId,
309
324
  projectId,
310
325
  event: kind,
311
- ts: now(),
326
+ ts: eventTs,
312
327
  version: opts.version,
313
328
  ci,
314
329
  // A SCALAR, so it needs the event build and the wire schema and no `blocks` entry — the same
@@ -333,20 +348,27 @@ export const createTelemetry = (opts) => {
333
348
  ...(extra?.refusal !== undefined ? { refusal: extra.refusal } : {}),
334
349
  ...(extra?.outage !== undefined ? { outage: extra.outage } : {}),
335
350
  ...(extra?.instrumentation !== undefined ? { instrumentation: extra.instrumentation } : {}),
336
- ...(extra?.stall !== undefined ? { stall: extra.stall } : {}),
337
351
  // A SCALAR, so it needs the event build and the wire schema but no entry in `blocks` below —
338
352
  // there is nothing to flatten. It still needs both of those, which is the trap `outage` fell
339
353
  // into: a field declared on TelemetryExtra and missing from either one is dropped in silence.
340
354
  // Always present now, rather than only when a caller remembered to pass it. A per-call
341
355
  // override still wins so a command that genuinely knows better can say so.
342
356
  installSource: extra?.installSource ?? installSource,
357
+ // Enterprise activation: three SCALARS, so they need the event build and the wire schema and no
358
+ // `blocks` entry. Resolved per event off the event's own clock rather than once at startup — an
359
+ // eleven-hour session must not report the status it booted with. All three are absent unless a
360
+ // release has an issuer key baked, so an OSS payload is byte-identical to before.
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),
343
365
  };
344
366
  // Map the core contract onto PostHog's capture shape: id/name/time move up, the rest are properties.
345
367
  // The feedback body is FLATTENED into `feedback_*` properties rather than sent as a nested object:
346
368
  // PostHog filters and breakdowns operate on top-level properties, so a nested `feedback.stack`
347
369
  // would be invisible to the exact "which stacks report the most bugs" question this exists to
348
370
  // answer. One prefix keeps them grouped in the UI without a nested path.
349
- const { anonymousId: distinctId, event: name, ts, sessionId: eventSessionId, feedback, session, project, verification, versionChange, crash, identity, connection, init, bug, refusal, outage, instrumentation, stall, ...rest } = event;
371
+ const { anonymousId: distinctId, event: name, ts, sessionId: eventSessionId, feedback, session, project, verification, versionChange, crash, identity, connection, init, bug, refusal, outage, instrumentation, ...rest } = event;
350
372
  // `$session_id` is PostHog's OWN session property, so sending ours under that name lights up
351
373
  // its built-in session views and funnels for free rather than requiring custom HogQL everywhere.
352
374
  // `sessionId` rides only on events that happened inside a daemon run. A one-shot CLI command
@@ -377,7 +399,6 @@ export const createTelemetry = (opts) => {
377
399
  refusal,
378
400
  outage,
379
401
  instrumentation,
380
- stall,
381
402
  };
382
403
  for (const [prefix, block] of Object.entries(blocks)) {
383
404
  for (const [key, value] of Object.entries(block ?? {})) {
@@ -388,7 +409,15 @@ export const createTelemetry = (opts) => {
388
409
  event: name,
389
410
  distinct_id: distinctId,
390
411
  timestamp: new Date(ts).toISOString(),
391
- properties: { ...properties, ...POSTHOG_PERSONLESS },
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
+ },
392
421
  };
393
422
  const body = JSON.stringify({ api_key: apiKey, batch: [capture] });
394
423
  const filePath = env[Env.FILE];