diff --git a/README.md b/README.md index 8dc557d..154f5dd 100644 --- a/README.md +++ b/README.md @@ -46,11 +46,15 @@ node ~/.gradata/plugin/setup/doctor.js ## Privacy -- Gradata does not collect telemetry. No data leaves your machine. Local files only. +- Telemetry is **opt-in only** via `GRADATA_TELEMETRY=1` (default is off). +- Opt-in telemetry sends only aggregate counters (`wau_ping`, `corrections_captured`, `rules_graduated`), plugin version, UTC timestamp, and an anonymous `user_id` (sha256 of local install ID). +- No prompt text, file paths, emails, API keys, lesson content, or correction payloads are sent. - All data stays local under `~/.gradata/`. - The daemon binds to `127.0.0.1` only — no network exposure. - Cloud sync is optional and only runs when you configure an API key. +Telemetry endpoint defaults to `https://api.gradata.ai/telemetry/plugin` and can be overridden for testing with `GRADATA_TELEMETRY_ENDPOINT`. + ## Supported agent CLIs - **Claude Code** — installer also creates `~/.claude/plugins/gradata` diff --git a/hooks/lib/telemetry.js b/hooks/lib/telemetry.js new file mode 100644 index 0000000..4bacef0 --- /dev/null +++ b/hooks/lib/telemetry.js @@ -0,0 +1,115 @@ +#!/usr/bin/env node +const crypto = require('crypto'); +const fs = require('fs'); +const https = require('https'); +const os = require('os'); +const path = require('path'); + +const GRADATA_HOME = process.env.GRADATA_HOME || path.join(os.homedir(), '.gradata'); +const INSTALL_ID_PATH = path.join(GRADATA_HOME, 'install_id'); +const TELEMETRY_ENDPOINT = process.env.GRADATA_TELEMETRY_ENDPOINT || 'https://api.gradata.ai/telemetry/plugin'; +const TELEMETRY_TIMEOUT_MS = 1500; + +let cachedAnonUserId = null; +let cachedPluginVersion = null; + +function telemetryEnabled() { + return process.env.GRADATA_TELEMETRY === '1'; +} + +function ensureInstallId() { + try { + if (fs.existsSync(INSTALL_ID_PATH)) { + return fs.readFileSync(INSTALL_ID_PATH, 'utf8').trim(); + } + fs.mkdirSync(path.dirname(INSTALL_ID_PATH), { recursive: true }); + const installId = crypto.randomUUID(); + fs.writeFileSync(INSTALL_ID_PATH, `${installId}\n`, { mode: 0o600 }); + return installId; + } catch { + return ''; + } +} + +function getAnonymousUserId() { + if (cachedAnonUserId) return cachedAnonUserId; + const installId = ensureInstallId(); + if (!installId) return ''; + cachedAnonUserId = crypto.createHash('sha256').update(installId).digest('hex'); + return cachedAnonUserId; +} + +function getPluginVersion() { + if (cachedPluginVersion) return cachedPluginVersion; + try { + const pluginJsonPath = path.resolve(__dirname, '../../.claude-plugin/plugin.json'); + const raw = JSON.parse(fs.readFileSync(pluginJsonPath, 'utf8')); + cachedPluginVersion = typeof raw.version === 'string' ? raw.version : 'unknown'; + } catch { + cachedPluginVersion = 'unknown'; + } + return cachedPluginVersion; +} + +function postJson(url, payload) { + return new Promise((resolve) => { + let requestUrl; + try { + requestUrl = new URL(url); + } catch { + resolve(false); + return; + } + + const body = JSON.stringify(payload); + const req = https.request( + { + protocol: requestUrl.protocol, + hostname: requestUrl.hostname, + port: requestUrl.port || 443, + path: `${requestUrl.pathname}${requestUrl.search}`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }, + timeout: TELEMETRY_TIMEOUT_MS, + }, + (res) => { + res.resume(); + resolve(res.statusCode >= 200 && res.statusCode < 300); + } + ); + + req.on('error', () => resolve(false)); + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + req.write(body); + req.end(); + }); +} + +async function sendTelemetryMetric(metric, count = 1) { + if (!telemetryEnabled()) return false; + const userId = getAnonymousUserId(); + if (!userId) return false; + if (typeof metric !== 'string' || !metric.trim()) return false; + if (!Number.isFinite(count) || count <= 0) return false; + + return postJson(TELEMETRY_ENDPOINT, { + event: 'plugin_metric', + metric, + count: Math.floor(count), + user_id: userId, + ts: new Date().toISOString(), + plugin_version: getPluginVersion(), + }); +} + +module.exports = { + telemetryEnabled, + getAnonymousUserId, + sendTelemetryMetric, +}; diff --git a/hooks/post-edit.js b/hooks/post-edit.js index d46ff3f..e4f1267 100644 --- a/hooks/post-edit.js +++ b/hooks/post-edit.js @@ -1,6 +1,7 @@ #!/usr/bin/env node const { callDaemon } = require('./lib/daemon-client.js'); const { readHookInput, WRITE_TOOLS } = require('./lib/hook-input.js'); +const { sendTelemetryMetric } = require('./lib/telemetry.js'); (async () => { try { const eventData = readHookInput(); @@ -17,5 +18,6 @@ const { readHookInput, WRITE_TOOLS } = require('./lib/hook-input.js'); old_string: oldStr, new_string: newStr, file_path: filePath, session_id: sessionId, }, 1000); + await sendTelemetryMetric('corrections_captured', 1); } catch (e) { /* Best-effort — never block editing */ } })(); diff --git a/hooks/session-start.js b/hooks/session-start.js index 6f3912c..c752e78 100644 --- a/hooks/session-start.js +++ b/hooks/session-start.js @@ -1,10 +1,12 @@ #!/usr/bin/env node const { callDaemon } = require('./lib/daemon-client.js'); const { readHookInput } = require('./lib/hook-input.js'); +const { sendTelemetryMetric } = require('./lib/telemetry.js'); (async () => { try { const eventData = readHookInput(); const sessionId = eventData.session_id || `s_${Date.now()}`; + await sendTelemetryMetric('wau_ping', 1); const result = await callDaemon('/apply-rules', { prompt: '', session_id: sessionId }, 3000); if (!result) { process.stderr.write('[gradata] Daemon not available — corrections will not be captured this session\n'); diff --git a/hooks/session-stop.js b/hooks/session-stop.js index 4b852d3..9b1aa9b 100644 --- a/hooks/session-stop.js +++ b/hooks/session-stop.js @@ -1,6 +1,7 @@ #!/usr/bin/env node const { callDaemon } = require('./lib/daemon-client.js'); const { readHookInput } = require('./lib/hook-input.js'); +const { sendTelemetryMetric } = require('./lib/telemetry.js'); (async () => { try { const eventData = readHookInput(); @@ -14,6 +15,7 @@ const { readHookInput } = require('./lib/hook-input.js'); if (c > 0 || g > 0) { process.stderr.write(`[gradata] Session end: ${c} corrections, ${i} instructions, ${g} graduated\n`); } + if (g > 0) await sendTelemetryMetric('rules_graduated', g); } const maintainResult = await callDaemon('/maintain', { tasks: ['manifest', 'patterns'] }, 10000); diff --git a/setup/install.js b/setup/install.js index 4aeed4a..9350494 100644 --- a/setup/install.js +++ b/setup/install.js @@ -116,6 +116,122 @@ async function ask(question) { return new Promise(resolve => rl.question(question, ans => { rl.close(); resolve(ans); })); } +// --- Starter brain seeding --------------------------------------------------- + +// Feature flag: check env GRADATA_STARTER_BRAIN=true or config.toml +// [starter_brain] section with enabled = true. Default: false. +function isStarterBrainEnabled() { + if (process.env.GRADATA_STARTER_BRAIN === 'true') return true; + if (process.env.GRADATA_STARTER_BRAIN === '1') return true; + const configPath = path.join(GRADATA_HOME, 'config.toml'); + if (!fs.existsSync(configPath)) return false; + try { + const lines = fs.readFileSync(configPath, 'utf8').split('\n'); + let inSection = false; + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed === '[starter_brain]') { inSection = true; continue; } + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { inSection = false; continue; } + if (inSection && /^enabled\s*=\s*true\s*$/.test(trimmed)) return true; + } + return false; + } catch { return false; } +} + +// 10 safe starter rules. Id, title, description, tier. +// All rules are safe defaults: no dangerous file ops, no PII, no production mutators. +const STARTER_RULES = [ + { + id: 'starter-01', + title: 'Always run tests before committing code', + description: 'Run the full test suite before every commit to catch regressions early.', + tier: 'RULE' + }, + { + id: 'starter-02', + title: "Don't use default exports in TypeScript files", + description: 'Prefer named exports over default exports for better IDE support and tree-shaking.', + tier: 'RULE' + }, + { + id: 'starter-03', + title: 'Wrap HTTP fetch calls in try/catch blocks', + description: 'Always handle network errors by wrapping fetch, axios, or other HTTP calls in try/catch.', + tier: 'RULE' + }, + { + id: 'starter-04', + title: "Don't push directly to main — use feature branches", + description: 'All work must ship via feature branches and pull requests. Never push to main directly.', + tier: 'RULE' + }, + { + id: 'starter-05', + title: 'Run the formatter before committing', + description: 'Run the project formatter (e.g., prettier, biome) before every commit.', + tier: 'RULE' + }, + { + id: 'starter-06', + title: "Don't commit secrets or API keys to the repository", + description: 'Never commit credentials, tokens, or API keys. Use environment variables or a secrets manager.', + tier: 'RULE' + }, + { + id: 'starter-07', + title: 'Use descriptive variable names — no single-letter vars except loop counters', + description: 'Variable names should clearly describe their purpose. Reserve single-letter names for loop indices only.', + tier: 'RULE' + }, + { + id: 'starter-08', + title: 'Keep functions under 50 lines — break up larger ones', + description: 'Functions longer than 50 lines should be split into smaller, focused functions.', + tier: 'PATTERN' + }, + { + id: 'starter-09', + title: 'Write tests for new features before implementing them', + description: 'Follow test-driven development: write failing tests first, then implement to make them pass.', + tier: 'RULE' + }, + { + id: 'starter-10', + title: "Don't leave console.log statements in production code", + description: 'Remove debug logging before merging. Use a proper logger for intentional production logging.', + tier: 'RULE' + } +]; + +function seedStarterBrain() { + if (!isStarterBrainEnabled()) { + console.log('Starter brain not enabled — skipping seed.'); + return; + } + const brainRulesDir = path.join(GRADATA_HOME, 'brain', 'rules'); + fs.mkdirSync(brainRulesDir, { recursive: true }); + const starterPath = path.join(brainRulesDir, 'starter.json'); + + // Idempotency: if file exists with the expected rule IDs, skip. + if (fs.existsSync(starterPath)) { + try { + const existing = JSON.parse(fs.readFileSync(starterPath, 'utf8')); + if (Array.isArray(existing)) { + const existingIds = new Set(existing.map(r => r.id)); + const expectedIds = new Set(STARTER_RULES.map(r => r.id)); + const allPresent = [...expectedIds].every(id => existingIds.has(id)); + if (allPresent && existing.length >= STARTER_RULES.length) { + console.log('Starter brain rules already seeded — skipping.'); + return; + } + } + } catch { /* corrupt or empty file — reseed below */ } + } + + fs.writeFileSync(starterPath, JSON.stringify(STARTER_RULES, null, 2) + '\n', 'utf8'); + console.log(`Starter brain rules seeded: ${starterPath}`); +} + // --- AGENTS.md patching ----------------------------------------------------- const BEGIN_MARKER = ''; @@ -277,6 +393,9 @@ async function main() { console.log(`AGENTS.md patch skipped: ${e.message}`); } + // Seed starter brain rules (gated by feature flag) + seedStarterBrain(); + console.log('\nReady.'); if (AUTO) { const doctor = path.join(GRADATA_HOME, 'plugin', 'setup', 'doctor.js'); diff --git a/tests/telemetry.test.js b/tests/telemetry.test.js new file mode 100644 index 0000000..e2a5ce2 --- /dev/null +++ b/tests/telemetry.test.js @@ -0,0 +1,39 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); + +test('telemetry disabled by default', () => { + const prev = process.env.GRADATA_TELEMETRY; + delete process.env.GRADATA_TELEMETRY; + delete require.cache[require.resolve('../hooks/lib/telemetry.js')]; + const { telemetryEnabled } = require('../hooks/lib/telemetry.js'); + assert.strictEqual(telemetryEnabled(), false); + if (prev === undefined) delete process.env.GRADATA_TELEMETRY; else process.env.GRADATA_TELEMETRY = prev; + delete require.cache[require.resolve('../hooks/lib/telemetry.js')]; +}); + +test('telemetry enabled with GRADATA_TELEMETRY=1', () => { + const prev = process.env.GRADATA_TELEMETRY; + process.env.GRADATA_TELEMETRY = '1'; + delete require.cache[require.resolve('../hooks/lib/telemetry.js')]; + const { telemetryEnabled } = require('../hooks/lib/telemetry.js'); + assert.strictEqual(telemetryEnabled(), true); + if (prev === undefined) delete process.env.GRADATA_TELEMETRY; else process.env.GRADATA_TELEMETRY = prev; + delete require.cache[require.resolve('../hooks/lib/telemetry.js')]; +}); + +test('anonymous user id is stable 64-char lowercase hex hash', () => { + const prevHome = process.env.GRADATA_HOME; + const os = require('node:os'); + const path = require('node:path'); + const fs = require('node:fs'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gradata-telemetry-')); + process.env.GRADATA_HOME = dir; + delete require.cache[require.resolve('../hooks/lib/telemetry.js')]; + const { getAnonymousUserId } = require('../hooks/lib/telemetry.js'); + const a = getAnonymousUserId(); + const b = getAnonymousUserId(); + assert.strictEqual(a, b); + assert.match(a, /^[0-9a-f]{64}$/); + if (prevHome === undefined) delete process.env.GRADATA_HOME; else process.env.GRADATA_HOME = prevHome; + delete require.cache[require.resolve('../hooks/lib/telemetry.js')]; +});