speclock 5.5.5 → 5.5.7
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/package.json +1 -1
- package/src/cli/index.js +354 -24
- package/src/core/compliance.js +1 -1
- package/src/core/guardian.js +466 -457
- package/src/core/hooks.js +109 -91
- package/src/core/pre-commit-semantic.js +102 -2
- package/src/core/semantics.js +3019 -2717
- package/src/core/telemetry.js +940 -852
- package/src/dashboard/index.html +2 -2
- package/src/mcp/http-server.js +1 -1
- package/src/mcp/server.js +1 -1
package/src/core/telemetry.js
CHANGED
|
@@ -1,852 +1,940 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SpecLock Telemetry & Analytics (v5.5)
|
|
3
|
-
* Opt-in anonymous usage analytics for product improvement.
|
|
4
|
-
*
|
|
5
|
-
* Two coexisting layers:
|
|
6
|
-
*
|
|
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.
|
|
31
|
-
*
|
|
32
|
-
* Developed by Sandeep Roy (https://github.com/sgroy10)
|
|
33
|
-
*/
|
|
34
|
-
|
|
35
|
-
import fs from "fs";
|
|
36
|
-
import path from "path";
|
|
37
|
-
import os from "os";
|
|
38
|
-
import crypto from "crypto";
|
|
39
|
-
|
|
40
|
-
// ---------------------------------------------------------------------------
|
|
41
|
-
// LEGACY (per-project) TELEMETRY — preserved for backward compatibility.
|
|
42
|
-
// ---------------------------------------------------------------------------
|
|
43
|
-
|
|
44
|
-
const TELEMETRY_FILE = "telemetry.json";
|
|
45
|
-
|
|
46
|
-
let _enabled = null;
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Check if telemetry is enabled (opt-in only) — legacy env-var path.
|
|
50
|
-
* Returns true if SPECLOCK_TELEMETRY is "true" or "1".
|
|
51
|
-
*/
|
|
52
|
-
export function isTelemetryEnabled() {
|
|
53
|
-
if (_enabled !== null) return _enabled;
|
|
54
|
-
const v = process.env.SPECLOCK_TELEMETRY;
|
|
55
|
-
_enabled = v === "true" || v === "1";
|
|
56
|
-
return _enabled;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Reset telemetry state (primarily for tests).
|
|
61
|
-
*/
|
|
62
|
-
export function resetTelemetry() {
|
|
63
|
-
_enabled = null;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function telemetryPath(root) {
|
|
67
|
-
return path.join(root, ".speclock", TELEMETRY_FILE);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function readTelemetryStore(root) {
|
|
71
|
-
const p = telemetryPath(root);
|
|
72
|
-
if (!fs.existsSync(p)) {
|
|
73
|
-
return createEmptyStore();
|
|
74
|
-
}
|
|
75
|
-
try {
|
|
76
|
-
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
77
|
-
} catch {
|
|
78
|
-
return createEmptyStore();
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function writeTelemetryStore(root, store) {
|
|
83
|
-
const p = telemetryPath(root);
|
|
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
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function createEmptyStore() {
|
|
93
|
-
return {
|
|
94
|
-
version: "1.0",
|
|
95
|
-
instanceId: generateLegacyInstanceId(),
|
|
96
|
-
createdAt: new Date().toISOString(),
|
|
97
|
-
updatedAt: new Date().toISOString(),
|
|
98
|
-
toolUsage: {},
|
|
99
|
-
conflicts: { total: 0, blocked: 0, advisory: 0 },
|
|
100
|
-
features: {},
|
|
101
|
-
sessions: { total: 0, tools: {} },
|
|
102
|
-
responseTimes: { samples: [], avgMs: 0 },
|
|
103
|
-
daily: {},
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function generateLegacyInstanceId() {
|
|
108
|
-
const bytes = new Uint8Array(8);
|
|
109
|
-
for (let i = 0; i < 8; i++) bytes[i] = Math.floor(Math.random() * 256);
|
|
110
|
-
return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Track a tool invocation (legacy).
|
|
115
|
-
*/
|
|
116
|
-
export function trackToolUsage(root, toolName, durationMs) {
|
|
117
|
-
if (!isTelemetryEnabled()) return;
|
|
118
|
-
try {
|
|
119
|
-
const store = readTelemetryStore(root);
|
|
120
|
-
if (!store.toolUsage[toolName]) {
|
|
121
|
-
store.toolUsage[toolName] = { count: 0, totalMs: 0, avgMs: 0 };
|
|
122
|
-
}
|
|
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
|
|
127
|
-
);
|
|
128
|
-
|
|
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++;
|
|
143
|
-
|
|
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 */ }
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Track a conflict check result (legacy).
|
|
158
|
-
*/
|
|
159
|
-
export function trackConflict(root, hasConflict, blocked) {
|
|
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
|
-
}
|
|
169
|
-
|
|
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++;
|
|
173
|
-
|
|
174
|
-
store.updatedAt = new Date().toISOString();
|
|
175
|
-
writeTelemetryStore(root, store);
|
|
176
|
-
} catch { /* swallow */ }
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Track feature adoption (legacy).
|
|
181
|
-
*/
|
|
182
|
-
export function trackFeature(root, featureName) {
|
|
183
|
-
if (!isTelemetryEnabled()) return;
|
|
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 */ }
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Track a session start (legacy).
|
|
198
|
-
*/
|
|
199
|
-
export function trackSession(root, toolName) {
|
|
200
|
-
if (!isTelemetryEnabled()) return;
|
|
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 */ }
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Get telemetry summary for dashboard display (legacy).
|
|
213
|
-
*/
|
|
214
|
-
export function getTelemetrySummary(root) {
|
|
215
|
-
if (!isTelemetryEnabled()) {
|
|
216
|
-
return {
|
|
217
|
-
enabled: false,
|
|
218
|
-
message:
|
|
219
|
-
"Telemetry is disabled. Set SPECLOCK_TELEMETRY=true or run 'speclock telemetry on' to enable.",
|
|
220
|
-
};
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
const store = readTelemetryStore(root);
|
|
224
|
-
|
|
225
|
-
const topTools = Object.entries(store.toolUsage)
|
|
226
|
-
.sort(([, a], [, b]) => b.count - a.count)
|
|
227
|
-
.slice(0, 10)
|
|
228
|
-
.map(([name, data]) => ({ name, ...data }));
|
|
229
|
-
|
|
230
|
-
const days = [];
|
|
231
|
-
for (let i = 6; i >= 0; i--) {
|
|
232
|
-
const d = new Date();
|
|
233
|
-
d.setDate(d.getDate() - i);
|
|
234
|
-
const key = d.toISOString().slice(0, 10);
|
|
235
|
-
days.push({ date: key, ...(store.daily[key] || { calls: 0, conflicts: 0 }) });
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
const features = Object.entries(store.features)
|
|
239
|
-
.sort(([, a], [, b]) => b.count - a.count)
|
|
240
|
-
.map(([name, data]) => ({ name, ...data }));
|
|
241
|
-
|
|
242
|
-
return {
|
|
243
|
-
enabled: true,
|
|
244
|
-
instanceId: store.instanceId,
|
|
245
|
-
updatedAt: store.updatedAt,
|
|
246
|
-
totalCalls: Object.values(store.toolUsage).reduce((sum, t) => sum + t.count, 0),
|
|
247
|
-
avgResponseMs: store.responseTimes.avgMs,
|
|
248
|
-
conflicts: store.conflicts,
|
|
249
|
-
sessions: store.sessions,
|
|
250
|
-
topTools,
|
|
251
|
-
dailyTrend: days,
|
|
252
|
-
features,
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* Flush legacy telemetry to a remote endpoint (if configured).
|
|
258
|
-
* Kept for backward compatibility with code that calls it directly.
|
|
259
|
-
*/
|
|
260
|
-
export async function flushToRemote(root) {
|
|
261
|
-
if (!isTelemetryEnabled()) return { sent: false, reason: "disabled" };
|
|
262
|
-
|
|
263
|
-
const endpoint = process.env.SPECLOCK_TELEMETRY_ENDPOINT;
|
|
264
|
-
if (!endpoint) return { sent: false, reason: "no endpoint configured" };
|
|
265
|
-
|
|
266
|
-
const summary = getTelemetrySummary(root);
|
|
267
|
-
if (!summary.enabled) return { sent: false, reason: "disabled" };
|
|
268
|
-
|
|
269
|
-
const payload = {
|
|
270
|
-
instanceId: summary.instanceId,
|
|
271
|
-
version: getSpeclockVersion(),
|
|
272
|
-
totalCalls: summary.totalCalls,
|
|
273
|
-
avgResponseMs: summary.avgResponseMs,
|
|
274
|
-
conflicts: summary.conflicts,
|
|
275
|
-
sessions: summary.sessions,
|
|
276
|
-
topTools: summary.topTools.map((t) => ({ name: t.name, count: t.count })),
|
|
277
|
-
features: summary.features.map((f) => ({ name: f.name, count: f.count })),
|
|
278
|
-
timestamp: new Date().toISOString(),
|
|
279
|
-
};
|
|
280
|
-
|
|
281
|
-
try {
|
|
282
|
-
const response = await fetch(endpoint, {
|
|
283
|
-
method: "POST",
|
|
284
|
-
headers: { "Content-Type": "application/json" },
|
|
285
|
-
body: JSON.stringify(payload),
|
|
286
|
-
signal: AbortSignal.timeout(1000),
|
|
287
|
-
});
|
|
288
|
-
return { sent: true, status: response.status };
|
|
289
|
-
} catch {
|
|
290
|
-
return { sent: false, reason: "network error" };
|
|
291
|
-
}
|
|
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
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* SpecLock Telemetry & Analytics (v5.5)
|
|
3
|
+
* Opt-in anonymous usage analytics for product improvement.
|
|
4
|
+
*
|
|
5
|
+
* Two coexisting layers:
|
|
6
|
+
*
|
|
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.
|
|
31
|
+
*
|
|
32
|
+
* Developed by Sandeep Roy (https://github.com/sgroy10)
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import fs from "fs";
|
|
36
|
+
import path from "path";
|
|
37
|
+
import os from "os";
|
|
38
|
+
import crypto from "crypto";
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// LEGACY (per-project) TELEMETRY — preserved for backward compatibility.
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
const TELEMETRY_FILE = "telemetry.json";
|
|
45
|
+
|
|
46
|
+
let _enabled = null;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Check if telemetry is enabled (opt-in only) — legacy env-var path.
|
|
50
|
+
* Returns true if SPECLOCK_TELEMETRY is "true" or "1".
|
|
51
|
+
*/
|
|
52
|
+
export function isTelemetryEnabled() {
|
|
53
|
+
if (_enabled !== null) return _enabled;
|
|
54
|
+
const v = process.env.SPECLOCK_TELEMETRY;
|
|
55
|
+
_enabled = v === "true" || v === "1";
|
|
56
|
+
return _enabled;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Reset telemetry state (primarily for tests).
|
|
61
|
+
*/
|
|
62
|
+
export function resetTelemetry() {
|
|
63
|
+
_enabled = null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function telemetryPath(root) {
|
|
67
|
+
return path.join(root, ".speclock", TELEMETRY_FILE);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readTelemetryStore(root) {
|
|
71
|
+
const p = telemetryPath(root);
|
|
72
|
+
if (!fs.existsSync(p)) {
|
|
73
|
+
return createEmptyStore();
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
77
|
+
} catch {
|
|
78
|
+
return createEmptyStore();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function writeTelemetryStore(root, store) {
|
|
83
|
+
const p = telemetryPath(root);
|
|
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
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function createEmptyStore() {
|
|
93
|
+
return {
|
|
94
|
+
version: "1.0",
|
|
95
|
+
instanceId: generateLegacyInstanceId(),
|
|
96
|
+
createdAt: new Date().toISOString(),
|
|
97
|
+
updatedAt: new Date().toISOString(),
|
|
98
|
+
toolUsage: {},
|
|
99
|
+
conflicts: { total: 0, blocked: 0, advisory: 0 },
|
|
100
|
+
features: {},
|
|
101
|
+
sessions: { total: 0, tools: {} },
|
|
102
|
+
responseTimes: { samples: [], avgMs: 0 },
|
|
103
|
+
daily: {},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function generateLegacyInstanceId() {
|
|
108
|
+
const bytes = new Uint8Array(8);
|
|
109
|
+
for (let i = 0; i < 8; i++) bytes[i] = Math.floor(Math.random() * 256);
|
|
110
|
+
return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Track a tool invocation (legacy).
|
|
115
|
+
*/
|
|
116
|
+
export function trackToolUsage(root, toolName, durationMs) {
|
|
117
|
+
if (!isTelemetryEnabled()) return;
|
|
118
|
+
try {
|
|
119
|
+
const store = readTelemetryStore(root);
|
|
120
|
+
if (!store.toolUsage[toolName]) {
|
|
121
|
+
store.toolUsage[toolName] = { count: 0, totalMs: 0, avgMs: 0 };
|
|
122
|
+
}
|
|
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
|
|
127
|
+
);
|
|
128
|
+
|
|
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++;
|
|
143
|
+
|
|
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 */ }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Track a conflict check result (legacy).
|
|
158
|
+
*/
|
|
159
|
+
export function trackConflict(root, hasConflict, blocked) {
|
|
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
|
+
}
|
|
169
|
+
|
|
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++;
|
|
173
|
+
|
|
174
|
+
store.updatedAt = new Date().toISOString();
|
|
175
|
+
writeTelemetryStore(root, store);
|
|
176
|
+
} catch { /* swallow */ }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Track feature adoption (legacy).
|
|
181
|
+
*/
|
|
182
|
+
export function trackFeature(root, featureName) {
|
|
183
|
+
if (!isTelemetryEnabled()) return;
|
|
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 */ }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Track a session start (legacy).
|
|
198
|
+
*/
|
|
199
|
+
export function trackSession(root, toolName) {
|
|
200
|
+
if (!isTelemetryEnabled()) return;
|
|
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 */ }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Get telemetry summary for dashboard display (legacy).
|
|
213
|
+
*/
|
|
214
|
+
export function getTelemetrySummary(root) {
|
|
215
|
+
if (!isTelemetryEnabled()) {
|
|
216
|
+
return {
|
|
217
|
+
enabled: false,
|
|
218
|
+
message:
|
|
219
|
+
"Telemetry is disabled. Set SPECLOCK_TELEMETRY=true or run 'speclock telemetry on' to enable.",
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const store = readTelemetryStore(root);
|
|
224
|
+
|
|
225
|
+
const topTools = Object.entries(store.toolUsage)
|
|
226
|
+
.sort(([, a], [, b]) => b.count - a.count)
|
|
227
|
+
.slice(0, 10)
|
|
228
|
+
.map(([name, data]) => ({ name, ...data }));
|
|
229
|
+
|
|
230
|
+
const days = [];
|
|
231
|
+
for (let i = 6; i >= 0; i--) {
|
|
232
|
+
const d = new Date();
|
|
233
|
+
d.setDate(d.getDate() - i);
|
|
234
|
+
const key = d.toISOString().slice(0, 10);
|
|
235
|
+
days.push({ date: key, ...(store.daily[key] || { calls: 0, conflicts: 0 }) });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const features = Object.entries(store.features)
|
|
239
|
+
.sort(([, a], [, b]) => b.count - a.count)
|
|
240
|
+
.map(([name, data]) => ({ name, ...data }));
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
enabled: true,
|
|
244
|
+
instanceId: store.instanceId,
|
|
245
|
+
updatedAt: store.updatedAt,
|
|
246
|
+
totalCalls: Object.values(store.toolUsage).reduce((sum, t) => sum + t.count, 0),
|
|
247
|
+
avgResponseMs: store.responseTimes.avgMs,
|
|
248
|
+
conflicts: store.conflicts,
|
|
249
|
+
sessions: store.sessions,
|
|
250
|
+
topTools,
|
|
251
|
+
dailyTrend: days,
|
|
252
|
+
features,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Flush legacy telemetry to a remote endpoint (if configured).
|
|
258
|
+
* Kept for backward compatibility with code that calls it directly.
|
|
259
|
+
*/
|
|
260
|
+
export async function flushToRemote(root) {
|
|
261
|
+
if (!isTelemetryEnabled()) return { sent: false, reason: "disabled" };
|
|
262
|
+
|
|
263
|
+
const endpoint = process.env.SPECLOCK_TELEMETRY_ENDPOINT;
|
|
264
|
+
if (!endpoint) return { sent: false, reason: "no endpoint configured" };
|
|
265
|
+
|
|
266
|
+
const summary = getTelemetrySummary(root);
|
|
267
|
+
if (!summary.enabled) return { sent: false, reason: "disabled" };
|
|
268
|
+
|
|
269
|
+
const payload = {
|
|
270
|
+
instanceId: summary.instanceId,
|
|
271
|
+
version: getSpeclockVersion(),
|
|
272
|
+
totalCalls: summary.totalCalls,
|
|
273
|
+
avgResponseMs: summary.avgResponseMs,
|
|
274
|
+
conflicts: summary.conflicts,
|
|
275
|
+
sessions: summary.sessions,
|
|
276
|
+
topTools: summary.topTools.map((t) => ({ name: t.name, count: t.count })),
|
|
277
|
+
features: summary.features.map((f) => ({ name: f.name, count: f.count })),
|
|
278
|
+
timestamp: new Date().toISOString(),
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
const response = await fetch(endpoint, {
|
|
283
|
+
method: "POST",
|
|
284
|
+
headers: { "Content-Type": "application/json" },
|
|
285
|
+
body: JSON.stringify(payload),
|
|
286
|
+
signal: AbortSignal.timeout(1000),
|
|
287
|
+
});
|
|
288
|
+
return { sent: true, status: response.status };
|
|
289
|
+
} catch {
|
|
290
|
+
return { sent: false, reason: "network error" };
|
|
291
|
+
}
|
|
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
|
+
}
|
|
853
|
+
|
|
854
|
+
// ---------------------------------------------------------------------------
|
|
855
|
+
// USER-FACING USAGE STATS — surfaces the local telemetry.jsonl log back to
|
|
856
|
+
// the user via `speclock stats`. No network calls, no PII, purely local.
|
|
857
|
+
// ---------------------------------------------------------------------------
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Read ALL events from the local JSONL log (most recent last). Unlike
|
|
861
|
+
* readRecentEvents() this does NOT cap the list — the caller is expected to
|
|
862
|
+
* aggregate and then discard.
|
|
863
|
+
*/
|
|
864
|
+
export function readAllTelemetryEvents() {
|
|
865
|
+
try {
|
|
866
|
+
const p = eventsPath();
|
|
867
|
+
if (!fs.existsSync(p)) return [];
|
|
868
|
+
const raw = fs.readFileSync(p, "utf-8");
|
|
869
|
+
const lines = raw.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
|
870
|
+
return lines
|
|
871
|
+
.map((l) => {
|
|
872
|
+
try { return JSON.parse(l); } catch { return null; }
|
|
873
|
+
})
|
|
874
|
+
.filter(Boolean);
|
|
875
|
+
} catch {
|
|
876
|
+
return [];
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Build a plain-data aggregate of the user's local telemetry log. Always
|
|
882
|
+
* safe to call — returns zero-filled fields when telemetry is disabled or
|
|
883
|
+
* the log is missing. The CLI `stats` command renders this; tests assert
|
|
884
|
+
* against the shape directly.
|
|
885
|
+
*
|
|
886
|
+
* @param {{ events?: Array, recentLimit?: number, now?: Date }} [opts]
|
|
887
|
+
* - events: pre-supplied events (for tests). Defaults to reading ~/.speclock/telemetry.jsonl.
|
|
888
|
+
* - recentLimit: how many recent events to include (default 10).
|
|
889
|
+
* - now: clock override for deterministic tests.
|
|
890
|
+
*/
|
|
891
|
+
export function buildUsageStats(opts = {}) {
|
|
892
|
+
const events = Array.isArray(opts.events) ? opts.events : readAllTelemetryEvents();
|
|
893
|
+
const recentLimit = typeof opts.recentLimit === "number" ? opts.recentLimit : 10;
|
|
894
|
+
const now = opts.now instanceof Date ? opts.now : new Date();
|
|
895
|
+
|
|
896
|
+
const cfg = readTelemetryConfig();
|
|
897
|
+
const installId = getInstallId();
|
|
898
|
+
|
|
899
|
+
// First install timestamp — prefer config, fall back to earliest event.
|
|
900
|
+
let firstInstallIso = cfg.installedAt || null;
|
|
901
|
+
if (!firstInstallIso && events.length > 0) {
|
|
902
|
+
let earliest = null;
|
|
903
|
+
for (const e of events) {
|
|
904
|
+
const t = e && e.timestamp ? Date.parse(e.timestamp) : NaN;
|
|
905
|
+
if (Number.isFinite(t) && (earliest === null || t < earliest)) earliest = t;
|
|
906
|
+
}
|
|
907
|
+
if (earliest !== null) firstInstallIso = new Date(earliest).toISOString();
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Days since first install.
|
|
911
|
+
let daysActive = 0;
|
|
912
|
+
if (firstInstallIso) {
|
|
913
|
+
const t = Date.parse(firstInstallIso);
|
|
914
|
+
if (Number.isFinite(t)) {
|
|
915
|
+
daysActive = Math.max(0, Math.floor((now.getTime() - t) / (24 * 60 * 60 * 1000)));
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// Commands by type.
|
|
920
|
+
const commandsByType = {};
|
|
921
|
+
for (const e of events) {
|
|
922
|
+
const cmd = (e && e.command) || "unknown";
|
|
923
|
+
commandsByType[cmd] = (commandsByType[cmd] || 0) + 1;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// Recent events (most recent last).
|
|
927
|
+
const recent = events.slice(-recentLimit);
|
|
928
|
+
|
|
929
|
+
return {
|
|
930
|
+
telemetryEnabled: isTelemetryOptedIn(),
|
|
931
|
+
installId,
|
|
932
|
+
firstInstallIso,
|
|
933
|
+
daysActive,
|
|
934
|
+
totalEvents: events.length,
|
|
935
|
+
commandsByType,
|
|
936
|
+
recentEvents: recent,
|
|
937
|
+
eventsPath: eventsPath(),
|
|
938
|
+
configPath: configPath(),
|
|
939
|
+
};
|
|
940
|
+
}
|