speclock 5.5.4 → 5.5.5

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.
@@ -1,53 +1,68 @@
1
1
  /**
2
- * SpecLock Telemetry & Analytics (v3.5)
2
+ * SpecLock Telemetry & Analytics (v5.5)
3
3
  * Opt-in anonymous usage analytics for product improvement.
4
4
  *
5
- * DISABLED by default. Enable via SPECLOCK_TELEMETRY=true env var.
6
- * NEVER tracks: lock content, project names, file paths, PII.
7
- * ONLY tracks: tool usage counts, conflict rates, response times, feature adoption.
5
+ * Two coexisting layers:
8
6
  *
9
- * Data stored locally in .speclock/telemetry.json.
10
- * Optional remote endpoint via SPECLOCK_TELEMETRY_ENDPOINT env var.
7
+ * 1) Legacy per-project telemetry (v3.5+):
8
+ * Stored in <projectRoot>/.speclock/telemetry.json.
9
+ * Controlled by the SPECLOCK_TELEMETRY env var.
10
+ * Used by trackToolUsage / trackConflict / trackFeature / trackSession
11
+ * and surfaced by getTelemetrySummary.
12
+ *
13
+ * 2) Global opt-in CLI telemetry (investor request):
14
+ * Stored in ~/.speclock/telemetry.jsonl (append-only JSON lines)
15
+ * Config in ~/.speclock/telemetry.json ({ enabled: true|false, decidedAt })
16
+ * Install id in ~/.speclock/install-id (random UUID, generated once)
17
+ * Controlled by `speclock telemetry on|off` or SPECLOCK_TELEMETRY=1.
18
+ * Used by recordCommand() from the CLI entrypoint — fire-and-forget.
19
+ *
20
+ * Privacy:
21
+ * NEVER records: file contents, commit messages, lock content, user names,
22
+ * paths, IP addresses, or any personally identifying information.
23
+ * ONLY records: install-id (random UUID), version, platform, node version,
24
+ * which subcommand was run, exit code, enforcement mode, number of locks,
25
+ * count of rule files, list of MCP clients wired up, and days since install.
26
+ *
27
+ * Resilience:
28
+ * All telemetry operations are wrapped in try/catch and must NEVER block
29
+ * or break the caller. Remote sends use a 1-second timeout and swallow
30
+ * every error.
11
31
  *
12
32
  * Developed by Sandeep Roy (https://github.com/sgroy10)
13
33
  */
14
34
 
15
35
  import fs from "fs";
16
36
  import path from "path";
37
+ import os from "os";
38
+ import crypto from "crypto";
17
39
 
18
- const TELEMETRY_FILE = "telemetry.json";
19
- const FLUSH_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
20
- const MAX_EVENTS_BUFFER = 500;
40
+ // ---------------------------------------------------------------------------
41
+ // LEGACY (per-project) TELEMETRY preserved for backward compatibility.
42
+ // ---------------------------------------------------------------------------
21
43
 
22
- // --- Telemetry state ---
44
+ const TELEMETRY_FILE = "telemetry.json";
23
45
 
24
46
  let _enabled = null;
25
- let _buffer = [];
26
- let _flushTimer = null;
27
47
 
28
48
  /**
29
- * Check if telemetry is enabled (opt-in only)
49
+ * Check if telemetry is enabled (opt-in only) — legacy env-var path.
50
+ * Returns true if SPECLOCK_TELEMETRY is "true" or "1".
30
51
  */
31
52
  export function isTelemetryEnabled() {
32
53
  if (_enabled !== null) return _enabled;
33
- _enabled = process.env.SPECLOCK_TELEMETRY === "true";
54
+ const v = process.env.SPECLOCK_TELEMETRY;
55
+ _enabled = v === "true" || v === "1";
34
56
  return _enabled;
35
57
  }
36
58
 
37
59
  /**
38
- * Reset telemetry state (for testing)
60
+ * Reset telemetry state (primarily for tests).
39
61
  */
40
62
  export function resetTelemetry() {
41
63
  _enabled = null;
42
- _buffer = [];
43
- if (_flushTimer) {
44
- clearInterval(_flushTimer);
45
- _flushTimer = null;
46
- }
47
64
  }
48
65
 
49
- // --- Local telemetry store ---
50
-
51
66
  function telemetryPath(root) {
52
67
  return path.join(root, ".speclock", TELEMETRY_FILE);
53
68
  }
@@ -66,13 +81,18 @@ function readTelemetryStore(root) {
66
81
 
67
82
  function writeTelemetryStore(root, store) {
68
83
  const p = telemetryPath(root);
69
- fs.writeFileSync(p, JSON.stringify(store, null, 2));
84
+ try {
85
+ fs.mkdirSync(path.dirname(p), { recursive: true });
86
+ fs.writeFileSync(p, JSON.stringify(store, null, 2));
87
+ } catch {
88
+ /* swallow — telemetry must never break callers */
89
+ }
70
90
  }
71
91
 
72
92
  function createEmptyStore() {
73
93
  return {
74
94
  version: "1.0",
75
- instanceId: generateInstanceId(),
95
+ instanceId: generateLegacyInstanceId(),
76
96
  createdAt: new Date().toISOString(),
77
97
  updatedAt: new Date().toISOString(),
78
98
  toolUsage: {},
@@ -84,134 +104,129 @@ function createEmptyStore() {
84
104
  };
85
105
  }
86
106
 
87
- function generateInstanceId() {
88
- // Anonymous instance ID — no PII, just random hex
107
+ function generateLegacyInstanceId() {
89
108
  const bytes = new Uint8Array(8);
90
109
  for (let i = 0; i < 8; i++) bytes[i] = Math.floor(Math.random() * 256);
91
110
  return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");
92
111
  }
93
112
 
94
- // --- Tracking functions ---
95
-
96
113
  /**
97
- * Track a tool invocation
114
+ * Track a tool invocation (legacy).
98
115
  */
99
116
  export function trackToolUsage(root, toolName, durationMs) {
100
117
  if (!isTelemetryEnabled()) return;
101
-
102
- const store = readTelemetryStore(root);
103
-
104
- // Tool usage count
105
- if (!store.toolUsage[toolName]) {
106
- store.toolUsage[toolName] = { count: 0, totalMs: 0, avgMs: 0 };
107
- }
108
- store.toolUsage[toolName].count++;
109
- store.toolUsage[toolName].totalMs += (durationMs || 0);
110
- store.toolUsage[toolName].avgMs = Math.round(
111
- store.toolUsage[toolName].totalMs / store.toolUsage[toolName].count
112
- );
113
-
114
- // Response time sampling (keep last 100)
115
- if (durationMs) {
116
- store.responseTimes.samples.push(durationMs);
117
- if (store.responseTimes.samples.length > 100) {
118
- store.responseTimes.samples = store.responseTimes.samples.slice(-100);
118
+ try {
119
+ const store = readTelemetryStore(root);
120
+ if (!store.toolUsage[toolName]) {
121
+ store.toolUsage[toolName] = { count: 0, totalMs: 0, avgMs: 0 };
119
122
  }
120
- store.responseTimes.avgMs = Math.round(
121
- store.responseTimes.samples.reduce((a, b) => a + b, 0) / store.responseTimes.samples.length
123
+ store.toolUsage[toolName].count++;
124
+ store.toolUsage[toolName].totalMs += (durationMs || 0);
125
+ store.toolUsage[toolName].avgMs = Math.round(
126
+ store.toolUsage[toolName].totalMs / store.toolUsage[toolName].count
122
127
  );
123
- }
124
128
 
125
- // Daily counter
126
- const today = new Date().toISOString().slice(0, 10);
127
- if (!store.daily[today]) store.daily[today] = { calls: 0, conflicts: 0 };
128
- store.daily[today].calls++;
129
-
130
- // Trim daily entries older than 30 days
131
- const cutoff = new Date();
132
- cutoff.setDate(cutoff.getDate() - 30);
133
- const cutoffStr = cutoff.toISOString().slice(0, 10);
134
- for (const key of Object.keys(store.daily)) {
135
- if (key < cutoffStr) delete store.daily[key];
136
- }
129
+ if (durationMs) {
130
+ store.responseTimes.samples.push(durationMs);
131
+ if (store.responseTimes.samples.length > 100) {
132
+ store.responseTimes.samples = store.responseTimes.samples.slice(-100);
133
+ }
134
+ store.responseTimes.avgMs = Math.round(
135
+ store.responseTimes.samples.reduce((a, b) => a + b, 0) /
136
+ store.responseTimes.samples.length
137
+ );
138
+ }
139
+
140
+ const today = new Date().toISOString().slice(0, 10);
141
+ if (!store.daily[today]) store.daily[today] = { calls: 0, conflicts: 0 };
142
+ store.daily[today].calls++;
137
143
 
138
- store.updatedAt = new Date().toISOString();
139
- writeTelemetryStore(root, store);
144
+ const cutoff = new Date();
145
+ cutoff.setDate(cutoff.getDate() - 30);
146
+ const cutoffStr = cutoff.toISOString().slice(0, 10);
147
+ for (const key of Object.keys(store.daily)) {
148
+ if (key < cutoffStr) delete store.daily[key];
149
+ }
150
+
151
+ store.updatedAt = new Date().toISOString();
152
+ writeTelemetryStore(root, store);
153
+ } catch { /* swallow */ }
140
154
  }
141
155
 
142
156
  /**
143
- * Track a conflict check result
157
+ * Track a conflict check result (legacy).
144
158
  */
145
159
  export function trackConflict(root, hasConflict, blocked) {
146
160
  if (!isTelemetryEnabled()) return;
161
+ try {
162
+ const store = readTelemetryStore(root);
163
+ store.conflicts.total++;
164
+ if (blocked) {
165
+ store.conflicts.blocked++;
166
+ } else if (hasConflict) {
167
+ store.conflicts.advisory++;
168
+ }
147
169
 
148
- const store = readTelemetryStore(root);
149
- store.conflicts.total++;
150
- if (blocked) {
151
- store.conflicts.blocked++;
152
- } else if (hasConflict) {
153
- store.conflicts.advisory++;
154
- }
155
-
156
- const today = new Date().toISOString().slice(0, 10);
157
- if (!store.daily[today]) store.daily[today] = { calls: 0, conflicts: 0 };
158
- if (hasConflict) store.daily[today].conflicts++;
170
+ const today = new Date().toISOString().slice(0, 10);
171
+ if (!store.daily[today]) store.daily[today] = { calls: 0, conflicts: 0 };
172
+ if (hasConflict) store.daily[today].conflicts++;
159
173
 
160
- store.updatedAt = new Date().toISOString();
161
- writeTelemetryStore(root, store);
174
+ store.updatedAt = new Date().toISOString();
175
+ writeTelemetryStore(root, store);
176
+ } catch { /* swallow */ }
162
177
  }
163
178
 
164
179
  /**
165
- * Track feature adoption (which features are being used)
180
+ * Track feature adoption (legacy).
166
181
  */
167
182
  export function trackFeature(root, featureName) {
168
183
  if (!isTelemetryEnabled()) return;
169
-
170
- const store = readTelemetryStore(root);
171
- if (!store.features[featureName]) {
172
- store.features[featureName] = { firstUsed: new Date().toISOString(), count: 0 };
173
- }
174
- store.features[featureName].count++;
175
- store.features[featureName].lastUsed = new Date().toISOString();
176
-
177
- store.updatedAt = new Date().toISOString();
178
- writeTelemetryStore(root, store);
184
+ try {
185
+ const store = readTelemetryStore(root);
186
+ if (!store.features[featureName]) {
187
+ store.features[featureName] = { firstUsed: new Date().toISOString(), count: 0 };
188
+ }
189
+ store.features[featureName].count++;
190
+ store.features[featureName].lastUsed = new Date().toISOString();
191
+ store.updatedAt = new Date().toISOString();
192
+ writeTelemetryStore(root, store);
193
+ } catch { /* swallow */ }
179
194
  }
180
195
 
181
196
  /**
182
- * Track session start
197
+ * Track a session start (legacy).
183
198
  */
184
199
  export function trackSession(root, toolName) {
185
200
  if (!isTelemetryEnabled()) return;
186
-
187
- const store = readTelemetryStore(root);
188
- store.sessions.total++;
189
- if (!store.sessions.tools[toolName]) store.sessions.tools[toolName] = 0;
190
- store.sessions.tools[toolName]++;
191
-
192
- store.updatedAt = new Date().toISOString();
193
- writeTelemetryStore(root, store);
201
+ try {
202
+ const store = readTelemetryStore(root);
203
+ store.sessions.total++;
204
+ if (!store.sessions.tools[toolName]) store.sessions.tools[toolName] = 0;
205
+ store.sessions.tools[toolName]++;
206
+ store.updatedAt = new Date().toISOString();
207
+ writeTelemetryStore(root, store);
208
+ } catch { /* swallow */ }
194
209
  }
195
210
 
196
- // --- Analytics / Reporting ---
197
-
198
211
  /**
199
- * Get telemetry summary for dashboard display
212
+ * Get telemetry summary for dashboard display (legacy).
200
213
  */
201
214
  export function getTelemetrySummary(root) {
202
215
  if (!isTelemetryEnabled()) {
203
- return { enabled: false, message: "Telemetry is disabled. Set SPECLOCK_TELEMETRY=true to enable." };
216
+ return {
217
+ enabled: false,
218
+ message:
219
+ "Telemetry is disabled. Set SPECLOCK_TELEMETRY=true or run 'speclock telemetry on' to enable.",
220
+ };
204
221
  }
205
222
 
206
223
  const store = readTelemetryStore(root);
207
224
 
208
- // Top tools by usage
209
225
  const topTools = Object.entries(store.toolUsage)
210
226
  .sort(([, a], [, b]) => b.count - a.count)
211
227
  .slice(0, 10)
212
228
  .map(([name, data]) => ({ name, ...data }));
213
229
 
214
- // Daily trend (last 7 days)
215
230
  const days = [];
216
231
  for (let i = 6; i >= 0; i--) {
217
232
  const d = new Date();
@@ -220,7 +235,6 @@ export function getTelemetrySummary(root) {
220
235
  days.push({ date: key, ...(store.daily[key] || { calls: 0, conflicts: 0 }) });
221
236
  }
222
237
 
223
- // Feature adoption
224
238
  const features = Object.entries(store.features)
225
239
  .sort(([, a], [, b]) => b.count - a.count)
226
240
  .map(([name, data]) => ({ name, ...data }));
@@ -239,11 +253,9 @@ export function getTelemetrySummary(root) {
239
253
  };
240
254
  }
241
255
 
242
- // --- Remote telemetry (optional) ---
243
-
244
256
  /**
245
- * Flush telemetry to remote endpoint if configured.
246
- * Only sends anonymized aggregate data never lock content or PII.
257
+ * Flush legacy telemetry to a remote endpoint (if configured).
258
+ * Kept for backward compatibility with code that calls it directly.
247
259
  */
248
260
  export async function flushToRemote(root) {
249
261
  if (!isTelemetryEnabled()) return { sent: false, reason: "disabled" };
@@ -254,16 +266,15 @@ export async function flushToRemote(root) {
254
266
  const summary = getTelemetrySummary(root);
255
267
  if (!summary.enabled) return { sent: false, reason: "disabled" };
256
268
 
257
- // Build anonymized payload
258
269
  const payload = {
259
270
  instanceId: summary.instanceId,
260
- version: "5.5.2",
271
+ version: getSpeclockVersion(),
261
272
  totalCalls: summary.totalCalls,
262
273
  avgResponseMs: summary.avgResponseMs,
263
274
  conflicts: summary.conflicts,
264
275
  sessions: summary.sessions,
265
- topTools: summary.topTools.map(t => ({ name: t.name, count: t.count })),
266
- features: summary.features.map(f => ({ name: f.name, count: f.count })),
276
+ topTools: summary.topTools.map((t) => ({ name: t.name, count: t.count })),
277
+ features: summary.features.map((f) => ({ name: f.name, count: f.count })),
267
278
  timestamp: new Date().toISOString(),
268
279
  };
269
280
 
@@ -272,10 +283,570 @@ export async function flushToRemote(root) {
272
283
  method: "POST",
273
284
  headers: { "Content-Type": "application/json" },
274
285
  body: JSON.stringify(payload),
275
- signal: AbortSignal.timeout(5000),
286
+ signal: AbortSignal.timeout(1000),
276
287
  });
277
288
  return { sent: true, status: response.status };
278
289
  } catch {
279
290
  return { sent: false, reason: "network error" };
280
291
  }
281
292
  }
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // GLOBAL OPT-IN CLI TELEMETRY (investor-requested)
296
+ // ---------------------------------------------------------------------------
297
+ //
298
+ // This layer is what the `speclock telemetry on|off|status|clear` commands
299
+ // operate on, and is what the CLI entrypoint feeds via recordCommand().
300
+ // It lives under the user's home directory so the decision persists across
301
+ // projects:
302
+ //
303
+ // ~/.speclock/install-id single-line random UUID, generated once
304
+ // ~/.speclock/telemetry.json { enabled: bool, decidedAt: iso, installedAt: iso }
305
+ // ~/.speclock/telemetry.jsonl append-only JSON-lines event log
306
+ //
307
+ // The default remote endpoint is the SpecLock Railway deploy. It can be
308
+ // overridden with SPECLOCK_TELEMETRY_ENDPOINT, or disabled entirely by
309
+ // setting SPECLOCK_TELEMETRY_ENDPOINT=off. When unreachable, events are
310
+ // still written locally so the data shape can be validated without a server.
311
+
312
+ export const TELEMETRY_DEFAULT_ENDPOINT =
313
+ "https://speclock-mcp-production.up.railway.app/telemetry";
314
+
315
+ function homeDir() {
316
+ try {
317
+ return os.homedir();
318
+ } catch {
319
+ return process.env.HOME || process.env.USERPROFILE || ".";
320
+ }
321
+ }
322
+
323
+ export function getTelemetryDir() {
324
+ return path.join(homeDir(), ".speclock");
325
+ }
326
+
327
+ function ensureTelemetryDir() {
328
+ const dir = getTelemetryDir();
329
+ try {
330
+ fs.mkdirSync(dir, { recursive: true });
331
+ } catch { /* swallow */ }
332
+ return dir;
333
+ }
334
+
335
+ function configPath() {
336
+ return path.join(getTelemetryDir(), "telemetry.json");
337
+ }
338
+
339
+ function eventsPath() {
340
+ return path.join(getTelemetryDir(), "telemetry.jsonl");
341
+ }
342
+
343
+ function installIdPath() {
344
+ return path.join(getTelemetryDir(), "install-id");
345
+ }
346
+
347
+ /**
348
+ * Read the global telemetry config file. If missing, returns undecided state.
349
+ */
350
+ export function readTelemetryConfig() {
351
+ try {
352
+ const p = configPath();
353
+ if (!fs.existsSync(p)) return { enabled: null, decidedAt: null, installedAt: null };
354
+ const raw = fs.readFileSync(p, "utf-8");
355
+ const parsed = JSON.parse(raw);
356
+ return {
357
+ enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : null,
358
+ decidedAt: parsed.decidedAt || null,
359
+ installedAt: parsed.installedAt || null,
360
+ };
361
+ } catch {
362
+ return { enabled: null, decidedAt: null, installedAt: null };
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Persist the global telemetry config file. Always merges into any existing
368
+ * fields (e.g. installedAt is written only once).
369
+ */
370
+ export function writeTelemetryConfig(patch) {
371
+ try {
372
+ ensureTelemetryDir();
373
+ const current = readTelemetryConfig();
374
+ const next = { ...current, ...patch };
375
+ if (!next.installedAt) next.installedAt = new Date().toISOString();
376
+ fs.writeFileSync(configPath(), JSON.stringify(next, null, 2));
377
+ return next;
378
+ } catch {
379
+ return null;
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Ensures an install id exists and returns it. This is the ONLY stable
385
+ * identifier we emit. It is a random UUID — never contains PII.
386
+ */
387
+ export function getInstallId() {
388
+ try {
389
+ ensureTelemetryDir();
390
+ const p = installIdPath();
391
+ if (fs.existsSync(p)) {
392
+ const id = fs.readFileSync(p, "utf-8").trim();
393
+ if (id) return id;
394
+ }
395
+ const id =
396
+ typeof crypto.randomUUID === "function"
397
+ ? crypto.randomUUID()
398
+ : crypto.randomBytes(16).toString("hex");
399
+ fs.writeFileSync(p, id);
400
+ // Bootstrap installedAt if we just created the id.
401
+ const cfg = readTelemetryConfig();
402
+ if (!cfg.installedAt) writeTelemetryConfig({ installedAt: new Date().toISOString() });
403
+ return id;
404
+ } catch {
405
+ return "unknown";
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Returns true if the user has opted in globally. Honours the SPECLOCK_TELEMETRY
411
+ * env var as a strong override so CI and one-off runs can force-enable without
412
+ * touching disk. SPECLOCK_TELEMETRY=0/false forces disabled.
413
+ */
414
+ export function isTelemetryOptedIn() {
415
+ try {
416
+ const env = process.env.SPECLOCK_TELEMETRY;
417
+ if (env === "1" || env === "true") return true;
418
+ if (env === "0" || env === "false") return false;
419
+ const cfg = readTelemetryConfig();
420
+ return cfg.enabled === true;
421
+ } catch {
422
+ return false;
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Returns true when no decision has been made yet (no prompt shown, no env var).
428
+ */
429
+ export function hasTelemetryDecision() {
430
+ try {
431
+ const env = process.env.SPECLOCK_TELEMETRY;
432
+ if (env === "1" || env === "true" || env === "0" || env === "false") return true;
433
+ const cfg = readTelemetryConfig();
434
+ return cfg.enabled !== null;
435
+ } catch {
436
+ return false;
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Opt in (persistent). Returns the updated config.
442
+ */
443
+ export function enableTelemetry() {
444
+ getInstallId();
445
+ return writeTelemetryConfig({ enabled: true, decidedAt: new Date().toISOString() });
446
+ }
447
+
448
+ /**
449
+ * Opt out (persistent). Returns the updated config.
450
+ */
451
+ export function disableTelemetry() {
452
+ return writeTelemetryConfig({ enabled: false, decidedAt: new Date().toISOString() });
453
+ }
454
+
455
+ /**
456
+ * Clear the local event log. Does not change opt-in state.
457
+ */
458
+ export function clearTelemetryLog() {
459
+ try {
460
+ const p = eventsPath();
461
+ if (fs.existsSync(p)) fs.unlinkSync(p);
462
+ return { cleared: true };
463
+ } catch {
464
+ return { cleared: false };
465
+ }
466
+ }
467
+
468
+ /**
469
+ * Read the last N events from the local log (most recent last).
470
+ */
471
+ export function readRecentEvents(limit = 10) {
472
+ try {
473
+ const p = eventsPath();
474
+ if (!fs.existsSync(p)) return [];
475
+ const raw = fs.readFileSync(p, "utf-8");
476
+ const lines = raw.split(/\r?\n/).filter((l) => l.trim().length > 0);
477
+ const slice = lines.slice(-limit);
478
+ return slice
479
+ .map((l) => {
480
+ try { return JSON.parse(l); } catch { return null; }
481
+ })
482
+ .filter(Boolean);
483
+ } catch {
484
+ return [];
485
+ }
486
+ }
487
+
488
+ /**
489
+ * Count all events recorded in the local log.
490
+ */
491
+ export function countTelemetryEvents() {
492
+ try {
493
+ const p = eventsPath();
494
+ if (!fs.existsSync(p)) return 0;
495
+ const raw = fs.readFileSync(p, "utf-8");
496
+ return raw.split(/\r?\n/).filter((l) => l.trim().length > 0).length;
497
+ } catch {
498
+ return 0;
499
+ }
500
+ }
501
+
502
+ // --- Context collection (anonymous, non-PII only) ---
503
+
504
+ function getSpeclockVersion() {
505
+ try {
506
+ // Resolve the package.json of the installed speclock module relative
507
+ // to this file. Works both in the source tree and when installed via npm.
508
+ const here = path.dirname(new URL(import.meta.url).pathname);
509
+ // On win32, URL pathname starts with "/C:/..." — strip the leading slash.
510
+ const normalised = process.platform === "win32" && here.startsWith("/")
511
+ ? here.slice(1)
512
+ : here;
513
+ const pkgPath = path.resolve(normalised, "..", "..", "package.json");
514
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
515
+ return pkg.version || "unknown";
516
+ } catch {
517
+ return "unknown";
518
+ }
519
+ }
520
+
521
+ function daysSinceInstall() {
522
+ try {
523
+ const cfg = readTelemetryConfig();
524
+ if (!cfg.installedAt) return 0;
525
+ const installed = new Date(cfg.installedAt).getTime();
526
+ const now = Date.now();
527
+ return Math.max(0, Math.floor((now - installed) / (24 * 60 * 60 * 1000)));
528
+ } catch {
529
+ return 0;
530
+ }
531
+ }
532
+
533
+ function countLocksInProject(projectRoot) {
534
+ try {
535
+ const brainPath = path.join(projectRoot, ".speclock", "brain.json");
536
+ if (!fs.existsSync(brainPath)) return 0;
537
+ const brain = JSON.parse(fs.readFileSync(brainPath, "utf-8"));
538
+ const items = brain && brain.specLock && Array.isArray(brain.specLock.items)
539
+ ? brain.specLock.items
540
+ : [];
541
+ return items.filter((l) => l && l.active !== false).length;
542
+ } catch {
543
+ return 0;
544
+ }
545
+ }
546
+
547
+ function countRuleFilesInProject(projectRoot) {
548
+ // Keep the list in sync with guardian.js RULE_FILES (we duplicate it here
549
+ // so telemetry has zero runtime dependency on guardian).
550
+ const candidates = [
551
+ ".cursorrules",
552
+ ".cursor/rules/rules.mdc",
553
+ "CLAUDE.md",
554
+ "AGENTS.md",
555
+ ".github/copilot-instructions.md",
556
+ ".windsurfrules",
557
+ ".windsurf/rules/rules.md",
558
+ "GEMINI.md",
559
+ ".aider.conf.yml",
560
+ "COPILOT.md",
561
+ ".github/instructions.md",
562
+ ];
563
+ let count = 0;
564
+ for (const rel of candidates) {
565
+ try {
566
+ const full = path.join(projectRoot, rel);
567
+ if (fs.existsSync(full)) count++;
568
+ } catch { /* swallow */ }
569
+ }
570
+ return count;
571
+ }
572
+
573
+ function getEnforcementModeForProject(projectRoot) {
574
+ try {
575
+ const brainPath = path.join(projectRoot, ".speclock", "brain.json");
576
+ if (!fs.existsSync(brainPath)) return "unknown";
577
+ const brain = JSON.parse(fs.readFileSync(brainPath, "utf-8"));
578
+ const mode = brain && brain.enforcement && brain.enforcement.mode;
579
+ if (mode === "hard") return "hard";
580
+ if (mode === "advisory") return "warn";
581
+ return "warn"; // default
582
+ } catch {
583
+ return "unknown";
584
+ }
585
+ }
586
+
587
+ /**
588
+ * Detect which supported MCP clients have SpecLock wired up. Returns an
589
+ * array of client names (e.g. ["claude-code", "cursor"]). No file content
590
+ * is read except to look for the substring "speclock" in a JSON/TOML blob.
591
+ * Never reads from the project directory.
592
+ */
593
+ function detectMcpClientsConfigured() {
594
+ const home = homeDir();
595
+ const platform = process.platform;
596
+ const checks = [
597
+ { name: "claude-code", p: path.join(home, ".claude", "mcp.json") },
598
+ { name: "cursor", p: path.join(home, ".cursor", "mcp.json") },
599
+ { name: "windsurf", p: path.join(home, ".codeium", "windsurf", "mcp_config.json") },
600
+ { name: "codex", p: path.join(home, ".codex", "config.toml") },
601
+ ];
602
+
603
+ // Cline lives inside VS Code User settings.json.
604
+ if (platform === "win32") {
605
+ checks.push({
606
+ name: "cline",
607
+ p: path.join(
608
+ process.env.APPDATA || path.join(home, "AppData", "Roaming"),
609
+ "Code",
610
+ "User",
611
+ "settings.json"
612
+ ),
613
+ });
614
+ } else if (platform === "darwin") {
615
+ checks.push({
616
+ name: "cline",
617
+ p: path.join(home, "Library", "Application Support", "Code", "User", "settings.json"),
618
+ });
619
+ } else {
620
+ checks.push({
621
+ name: "cline",
622
+ p: path.join(home, ".config", "Code", "User", "settings.json"),
623
+ });
624
+ }
625
+
626
+ const found = [];
627
+ for (const c of checks) {
628
+ try {
629
+ if (!fs.existsSync(c.p)) continue;
630
+ const raw = fs.readFileSync(c.p, "utf-8");
631
+ if (raw && raw.toLowerCase().includes("speclock")) {
632
+ found.push(c.name);
633
+ }
634
+ } catch { /* swallow */ }
635
+ }
636
+ return found;
637
+ }
638
+
639
+ /**
640
+ * Build the anonymous payload for a single command invocation.
641
+ * Public so `telemetry status` can show exactly what would be sent.
642
+ */
643
+ export function buildTelemetryEvent({
644
+ command,
645
+ exitCode,
646
+ projectRoot = process.cwd(),
647
+ extra = {},
648
+ } = {}) {
649
+ return {
650
+ installId: getInstallId(),
651
+ version: getSpeclockVersion(),
652
+ os: process.platform,
653
+ nodeVersion: process.version,
654
+ command: command || "unknown",
655
+ exitCode: typeof exitCode === "number" ? exitCode : 0,
656
+ enforcementMode: getEnforcementModeForProject(projectRoot),
657
+ lockCount: countLocksInProject(projectRoot),
658
+ ruleFilesFound: countRuleFilesInProject(projectRoot),
659
+ mcpClientsConfigured: detectMcpClientsConfigured(),
660
+ daysSinceInstall: daysSinceInstall(),
661
+ timestamp: new Date().toISOString(),
662
+ ...extra,
663
+ };
664
+ }
665
+
666
+ /**
667
+ * Append an event to the local JSONL log.
668
+ */
669
+ function appendEvent(event) {
670
+ try {
671
+ ensureTelemetryDir();
672
+ fs.appendFileSync(eventsPath(), JSON.stringify(event) + "\n");
673
+ } catch { /* swallow */ }
674
+ }
675
+
676
+ /**
677
+ * Send an event to the remote endpoint with a 1-second timeout.
678
+ * Silently swallows all errors. Returns a Promise that never rejects.
679
+ */
680
+ async function sendEventRemote(event) {
681
+ try {
682
+ const raw = process.env.SPECLOCK_TELEMETRY_ENDPOINT;
683
+ if (raw === "off" || raw === "none" || raw === "0") return;
684
+ const endpoint = raw || TELEMETRY_DEFAULT_ENDPOINT;
685
+
686
+ let signal;
687
+ try {
688
+ if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") {
689
+ signal = AbortSignal.timeout(1000);
690
+ } else {
691
+ const ctl = new AbortController();
692
+ setTimeout(() => ctl.abort(), 1000).unref?.();
693
+ signal = ctl.signal;
694
+ }
695
+ } catch { /* ignore — send without signal */ }
696
+
697
+ await fetch(endpoint, {
698
+ method: "POST",
699
+ headers: { "Content-Type": "application/json" },
700
+ body: JSON.stringify(event),
701
+ signal,
702
+ });
703
+ } catch { /* swallow every failure */ }
704
+ }
705
+
706
+ /**
707
+ * Record a command invocation. Fire-and-forget: the caller is never blocked,
708
+ * no error ever reaches them. Writes to local JSONL immediately so the data
709
+ * shape can be validated without a running server, and also attempts a
710
+ * best-effort remote send behind a 1-second timeout.
711
+ *
712
+ * Usage from the CLI entrypoint:
713
+ *
714
+ * recordCommand("protect", 0);
715
+ *
716
+ * This function is safe to call from inside a `process.on('exit')` handler:
717
+ * the local JSONL write happens synchronously so it completes before the
718
+ * process terminates, while the remote HTTP send is scheduled via
719
+ * setImmediate (which will no-op on exit, but that's fine — the local log
720
+ * is the source of truth for validating the data shape).
721
+ *
722
+ * Safe to call even when telemetry is disabled — it will simply no-op.
723
+ */
724
+ export function recordCommand(command, exitCode, opts = {}) {
725
+ try {
726
+ if (!isTelemetryOptedIn()) return;
727
+ const event = buildTelemetryEvent({
728
+ command,
729
+ exitCode,
730
+ projectRoot: opts.projectRoot || process.cwd(),
731
+ extra: opts.extra || {},
732
+ });
733
+ // Synchronous local write — must complete even if called from an
734
+ // 'exit' handler where microtasks/timers are no longer scheduled.
735
+ try { appendEvent(event); } catch { /* swallow */ }
736
+ // Best-effort remote send — fire-and-forget behind a 1s timeout.
737
+ try {
738
+ if (typeof setImmediate === "function") {
739
+ setImmediate(() => { sendEventRemote(event); });
740
+ } else {
741
+ setTimeout(() => { sendEventRemote(event); }, 0);
742
+ }
743
+ } catch { /* swallow */ }
744
+ } catch { /* swallow */ }
745
+ }
746
+
747
+ /**
748
+ * Human-readable summary of the current opt-in state + last events.
749
+ * Used by `speclock telemetry status`.
750
+ */
751
+ export function getOptInTelemetryStatus({ eventLimit = 10 } = {}) {
752
+ try {
753
+ const cfg = readTelemetryConfig();
754
+ const envOverride = process.env.SPECLOCK_TELEMETRY;
755
+ const enabled = isTelemetryOptedIn();
756
+ return {
757
+ enabled,
758
+ decided: hasTelemetryDecision(),
759
+ decidedAt: cfg.decidedAt,
760
+ installedAt: cfg.installedAt,
761
+ installId: getInstallId(),
762
+ configPath: configPath(),
763
+ eventsPath: eventsPath(),
764
+ envOverride: envOverride || null,
765
+ endpoint:
766
+ process.env.SPECLOCK_TELEMETRY_ENDPOINT === "off"
767
+ ? null
768
+ : process.env.SPECLOCK_TELEMETRY_ENDPOINT || TELEMETRY_DEFAULT_ENDPOINT,
769
+ eventCount: countTelemetryEvents(),
770
+ recentEvents: readRecentEvents(eventLimit),
771
+ sampleEvent: buildTelemetryEvent({ command: "<sample>", exitCode: 0 }),
772
+ };
773
+ } catch {
774
+ return {
775
+ enabled: false,
776
+ decided: false,
777
+ decidedAt: null,
778
+ installedAt: null,
779
+ installId: "unknown",
780
+ configPath: configPath(),
781
+ eventsPath: eventsPath(),
782
+ envOverride: null,
783
+ endpoint: TELEMETRY_DEFAULT_ENDPOINT,
784
+ eventCount: 0,
785
+ recentEvents: [],
786
+ sampleEvent: null,
787
+ };
788
+ }
789
+ }
790
+
791
+ /**
792
+ * Prompts the user on stdin with a Y/N question. Resolves to true on "y"/"yes",
793
+ * false otherwise. Defaults to false if stdin is not a TTY or an error occurs.
794
+ */
795
+ export function promptTelemetryOptIn() {
796
+ return new Promise((resolve) => {
797
+ try {
798
+ if (!process.stdin.isTTY) return resolve(false);
799
+ process.stdout.write(`
800
+ Help improve SpecLock?
801
+ We collect anonymous usage data to understand which features matter.
802
+ We NEVER collect: file contents, commit messages, lock content, paths, names.
803
+ See: speclock telemetry status
804
+
805
+ Enable telemetry? [y/N]: `);
806
+ let buf = "";
807
+ const onData = (chunk) => {
808
+ buf += chunk.toString();
809
+ if (buf.includes("\n")) {
810
+ process.stdin.removeListener("data", onData);
811
+ process.stdin.pause();
812
+ const answer = buf.trim().toLowerCase();
813
+ resolve(answer === "y" || answer === "yes");
814
+ }
815
+ };
816
+ process.stdin.resume();
817
+ process.stdin.on("data", onData);
818
+ // Safety timeout — never block forever.
819
+ setTimeout(() => {
820
+ try { process.stdin.removeListener("data", onData); } catch {}
821
+ try { process.stdin.pause(); } catch {}
822
+ resolve(false);
823
+ }, 15000).unref?.();
824
+ } catch {
825
+ resolve(false);
826
+ }
827
+ });
828
+ }
829
+
830
+ /**
831
+ * Ensures a telemetry decision has been recorded. If none exists and we are
832
+ * attached to a TTY, prompts the user. Defaults to OFF for any non-interactive
833
+ * shell or on any error. Always persists the decision so we never prompt twice.
834
+ * Resolves to the final opt-in boolean.
835
+ */
836
+ export async function ensureTelemetryDecision() {
837
+ try {
838
+ if (hasTelemetryDecision()) return isTelemetryOptedIn();
839
+ const answer = await promptTelemetryOptIn();
840
+ if (answer) {
841
+ enableTelemetry();
842
+ console.log("Telemetry: ENABLED. Thank you! Run 'speclock telemetry off' to disable any time.");
843
+ return true;
844
+ } else {
845
+ disableTelemetry();
846
+ console.log("Telemetry: DISABLED. Run 'speclock telemetry on' to enable any time.");
847
+ return false;
848
+ }
849
+ } catch {
850
+ return false;
851
+ }
852
+ }