Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
115 changes: 115 additions & 0 deletions hooks/lib/telemetry.js
Original file line number Diff line number Diff line change
@@ -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,
};
2 changes: 2 additions & 0 deletions hooks/post-edit.js
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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 */ }
})();
2 changes: 2 additions & 0 deletions hooks/session-start.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down
2 changes: 2 additions & 0 deletions hooks/session-stop.js
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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);
Expand Down
119 changes: 119 additions & 0 deletions setup/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<!-- BEGIN GRADATA -->';
Expand Down Expand Up @@ -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');
Expand Down
39 changes: 39 additions & 0 deletions tests/telemetry.test.js
Original file line number Diff line number Diff line change
@@ -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')];
});