diff --git a/README.legacy.md b/README.legacy.md new file mode 100644 index 0000000..3279908 --- /dev/null +++ b/README.legacy.md @@ -0,0 +1,243 @@ +# Gradata Plugin + +AI that learns your judgment — one correction at a time. + +```sh +curl -fsSL https://raw.githubusercontent.com/Gradata/gradata-plugin/main/install.sh | sh +``` + +--- + +## How it works + +Every time you correct AI output, Gradata learns. After a few sessions, it +starts getting it right without being told. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ │ +│ 1. You correct 2. Daemon captures │ +│ ───────────── ───────────────── │ +│ │ +│ You edit Claude's The hook fires on │ +│ output — change a your edit. Payload: │ +│ word, add a type, old → new, file, │ +│ fix a style call. session id. │ +│ │ +│ │ │ │ +│ └───────────────────────┘ │ +│ │ │ +│ ▼ │ +│ 4. Auto-injects 3. Rule graduates │ +│ ──────────────── ──────────────── │ +│ │ +│ On session start, Lesson confidence │ +│ graduated rules rises with each │ +│ are injected into repetition. After │ +│ the system prompt 3+ corrections the │ +│ silently. No more lesson becomes a │ +│ same correction. durable rule. │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + + +![Gradata demo — rule graduating over three sessions](docs/demo-placeholder.gif) + +--- + +## Why this matters + +- **You correct once, not forever.** Gradata remembers corrections across + sessions so the same mistake stops recurring — no repeated prompting. + +- **Rules emerge from real use.** There is no rulebook to write. Your + corrections are the source of truth; the system extracts and strengthens + them automatically. + +- **Everything stays local.** No data leaves your machine. The daemon binds + to `127.0.0.1` only. Cloud sync is optional and content-free until you + opt in. + +--- + +## Install + +```sh +curl -fsSL https://raw.githubusercontent.com/Gradata/gradata-plugin/main/install.sh | sh +``` + +Or manually: + +```sh +git clone --depth 1 https://github.com/Gradata/gradata-plugin "$HOME/.gradata/plugin" +node "$HOME/.gradata/plugin/setup/install.js" --auto +``` + +Verify the install: + +```sh +node ~/.gradata/plugin/setup/doctor.js +``` + +## What this added to your setup + +- **Plugin checkout** at `~/.gradata/plugin/` (hooks + skills + setup). +- **AGENTS.md** updated with a Gradata section between `` + and `` markers (re-runs replace the section in place). +- **Daemon-ready config** at `~/.gradata/config.toml` pointing at a working + `python3 >= 3.10`. Install the SDK from git source to bring up the daemon + (PyPI publish coming soon; install from source for now): + + ```sh + pip install git+https://github.com/Gradata/gradata.git#subdirectory=Gradata + # or, on system Python (Debian/Ubuntu — handles PEP 668): + pip install --user git+https://github.com/Gradata/gradata.git#subdirectory=Gradata + # or with pipx (recommended for isolation): + pipx install git+https://github.com/Gradata/gradata.git#subdirectory=Gradata + ``` + +## Privacy + +- 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 + +The plugin works with any CLI that reads `AGENTS.md`. + +| CLI | Correction capture | Rule injection | +|---|---|---| +| Claude Code | ✅ | ✅ | +| Codex | ⬜ (coming) | ✅ | +| Hermes | ⬜ (coming) | ✅ | +| OpenCode | ⬜ (coming) | ✅ | + +Claude Code users also get `/gradata` slash commands. All others pick up the +Gradata block from `AGENTS.md` automatically on session start. + +--- + +## Graduation pipeline + +Corrections start weak and strengthen through repetition. + +``` +Session 1: You change an em dash to a comma in a draft email + -> Extracted: "Never use em dashes in email prose" + -> Lesson: INSTINCT (confidence 0.40) + +Session 2: Same correction again + -> PATTERN (confidence 0.60, 2+ fires) + +Session 3: No correction needed — rule fires correctly + -> RULE (confidence 0.90, 3+ fires, Beta-LB ≥ 0.75) + -> Auto-injected into every relevant session from now on +``` + +Rules that stop being useful decay. Rules that conflict get flagged. The +system self-corrects. + +Minimum graduation path: **3 corrections spanning 2+ sessions.** + +--- + +## Commands + +In Claude Code, run `/gradata `. In other CLIs, invoke the equivalent +skill by name (`gradata-status`, `gradata-doctor`, …). + +| Command | What it does | +|---|---| +| `status` | Brain health: rule count, lesson stats, session number | +| `doctor` | Diagnose daemon, config, plugin layout, AGENTS.md state | +| `review` | Review pending lessons; promote or reject | +| `promote` | Manually promote a lesson to a higher tier | +| `forget` | Remove a lesson or rule by ID | + +--- + +## Architecture + +``` ++-------------------------------------------+ +| Agent CLI (any AGENTS.md-aware runtime) | +| | +| SessionStart --> inject graduated rules | +| UserPrompt --> scope-match + detect | +| Edit --> capture correction | +| Stop --> graduation sweep | +| | | +| v | +| localhost HTTP daemon (Python) | +| | | +| v | +| ~/.gradata/projects// | +| lessons.md | system.db | events.jsonl | ++-------------------------------------------+ +``` + +The plugin communicates with a local Python daemon over HTTP on `127.0.0.1`. +All processing is local. The daemon manages the brain vault (lessons, rules, +events) per project. + +--- + +## Detection signals + +- **Explicit corrections** — edits to AI-generated output (diffs tracked by severity) +- **Implicit feedback** — phrases like "that's wrong", "stop doing X", "I told you before" +- **Acceptance signals** — a rule fires and output is not corrected (reinforcement) +- **Addition patterns** — repeatedly adding the same thing (type annotations, imports, headers) +- **Context switching** — different behavior expected in code vs email vs config + +--- + +## Privacy + +- No telemetry. No data leaves your machine. +- All state lives under `~/.gradata/`. +- Daemon binds to `127.0.0.1` only — no network exposure. +- Cloud sync is optional and only runs when you configure a Gradata API key. + +--- + +## Troubleshooting + +**"Daemon not available"** — Run `doctor` to diagnose. The daemon should +auto-start on session begin. + +**"No rules injecting"** — Graduation requires 3+ corrections across 2+ +sessions. Check `status` for pending lessons. + +**"Wrong Python"** — Update `python_path` in `~/.gradata/config.toml`. + +**"Plugin not loading"** — Verify `.claude-plugin/plugin.json` exists in the +plugin directory. Run `doctor`. + +--- + +## Requirements + +- Python 3.10+ +- Node.js 18+ +- An `AGENTS.md`-aware agent CLI +- `gradata` Python package: + + ```sh + pip install git+https://github.com/Gradata/gradata.git#subdirectory=Gradata + # or with pipx (recommended): + pipx install git+https://github.com/Gradata/gradata.git#subdirectory=Gradata + ``` + +## License + +Apache-2.0. See [LICENSE](LICENSE). diff --git a/README.md b/README.md index 355c224..da16b46 100644 --- a/README.md +++ b/README.md @@ -1,215 +1,17 @@ -# Gradata Plugin +# gradata-plugin (archived) -AI that learns your judgment — one correction at a time. +As of 2026-05-25, the Claude Code plugin is now shipped as part of the main SDK. +Install via: ```sh -curl -fsSL https://raw.githubusercontent.com/Gradata/gradata-plugin/main/install.sh | sh +pipx install gradata && gradata install --agent claude-code ``` ---- - -## How it works - -Every time you correct AI output, Gradata learns. After a few sessions, it -starts getting it right without being told. - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ │ -│ 1. You correct 2. Daemon captures │ -│ ───────────── ───────────────── │ -│ │ -│ You edit Claude's The hook fires on │ -│ output — change a your edit. Payload: │ -│ word, add a type, old → new, file, │ -│ fix a style call. session id. │ -│ │ -│ │ │ │ -│ └───────────────────────┘ │ -│ │ │ -│ ▼ │ -│ 4. Auto-injects 3. Rule graduates │ -│ ──────────────── ──────────────── │ -│ │ -│ On session start, Lesson confidence │ -│ graduated rules rises with each │ -│ are injected into repetition. After │ -│ the system prompt 3+ corrections the │ -│ silently. No more lesson becomes a │ -│ same correction. durable rule. │ -│ │ -└─────────────────────────────────────────────────────────────────┘ -``` - - -![Gradata demo — rule graduating over three sessions](docs/demo-placeholder.gif) - ---- - -## Why this matters - -- **You correct once, not forever.** Gradata remembers corrections across - sessions so the same mistake stops recurring — no repeated prompting. - -- **Rules emerge from real use.** There is no rulebook to write. Your - corrections are the source of truth; the system extracts and strengthens - them automatically. - -- **Everything stays local.** No data leaves your machine. The daemon binds - to `127.0.0.1` only. Cloud sync is optional and content-free until you - opt in. - ---- - -## Install - -```sh -curl -fsSL https://raw.githubusercontent.com/Gradata/gradata-plugin/main/install.sh | sh -``` - -Or manually: - -```sh -git clone --depth 1 https://github.com/Gradata/gradata-plugin "$HOME/.gradata/plugin" -node "$HOME/.gradata/plugin/setup/install.js" --auto -``` - -Verify the install: - -```sh -node ~/.gradata/plugin/setup/doctor.js -``` - ---- - -## Supported agent CLIs - -The plugin works with any CLI that reads `AGENTS.md`. - -| CLI | Correction capture | Rule injection | -|---|---|---| -| Claude Code | ✅ | ✅ | -| Codex | ⬜ (coming) | ✅ | -| Hermes | ⬜ (coming) | ✅ | -| OpenCode | ⬜ (coming) | ✅ | - -Claude Code users also get `/gradata` slash commands. All others pick up the -Gradata block from `AGENTS.md` automatically on session start. - ---- - -## Graduation pipeline - -Corrections start weak and strengthen through repetition. +For other agents (Codex, Hermes, OpenCode), the Gradata SDK now natively handles +hook injection via the `gradata install --agent ` command. -``` -Session 1: You change an em dash to a comma in a draft email - -> Extracted: "Never use em dashes in email prose" - -> Lesson: INSTINCT (confidence 0.40) - -Session 2: Same correction again - -> PATTERN (confidence 0.60, 2+ fires) - -Session 3: No correction needed — rule fires correctly - -> RULE (confidence 0.90, 3+ fires, Beta-LB ≥ 0.75) - -> Auto-injected into every relevant session from now on -``` - -Rules that stop being useful decay. Rules that conflict get flagged. The -system self-corrects. - -Minimum graduation path: **3 corrections spanning 2+ sessions.** +See https://github.com/Gradata/gradata for the canonical repo, docs, and issue tracker. --- -## Commands - -In Claude Code, run `/gradata `. In other CLIs, invoke the equivalent -skill by name (`gradata-status`, `gradata-doctor`, …). - -| Command | What it does | -|---|---| -| `status` | Brain health: rule count, lesson stats, session number | -| `doctor` | Diagnose daemon, config, plugin layout, AGENTS.md state | -| `review` | Review pending lessons; promote or reject | -| `promote` | Manually promote a lesson to a higher tier | -| `forget` | Remove a lesson or rule by ID | - ---- - -## Architecture - -``` -+-------------------------------------------+ -| Agent CLI (any AGENTS.md-aware runtime) | -| | -| SessionStart --> inject graduated rules | -| UserPrompt --> scope-match + detect | -| Edit --> capture correction | -| Stop --> graduation sweep | -| | | -| v | -| localhost HTTP daemon (Python) | -| | | -| v | -| ~/.gradata/projects// | -| lessons.md | system.db | events.jsonl | -+-------------------------------------------+ -``` - -The plugin communicates with a local Python daemon over HTTP on `127.0.0.1`. -All processing is local. The daemon manages the brain vault (lessons, rules, -events) per project. - ---- - -## Detection signals - -- **Explicit corrections** — edits to AI-generated output (diffs tracked by severity) -- **Implicit feedback** — phrases like "that's wrong", "stop doing X", "I told you before" -- **Acceptance signals** — a rule fires and output is not corrected (reinforcement) -- **Addition patterns** — repeatedly adding the same thing (type annotations, imports, headers) -- **Context switching** — different behavior expected in code vs email vs config - ---- - -## Privacy - -- No telemetry. No data leaves your machine. -- All state lives under `~/.gradata/`. -- Daemon binds to `127.0.0.1` only — no network exposure. -- Cloud sync is optional and only runs when you configure a Gradata API key. - ---- - -## Troubleshooting - -**"Daemon not available"** — Run `doctor` to diagnose. The daemon should -auto-start on session begin. - -**"No rules injecting"** — Graduation requires 3+ corrections across 2+ -sessions. Check `status` for pending lessons. - -**"Wrong Python"** — Update `python_path` in `~/.gradata/config.toml`. - -**"Plugin not loading"** — Verify `.claude-plugin/plugin.json` exists in the -plugin directory. Run `doctor`. - ---- - -## Requirements - -- Python 3.10+ -- Node.js 18+ -- An `AGENTS.md`-aware agent CLI -- `gradata` Python package: - - ```sh - pip install git+https://github.com/Gradata/gradata.git#subdirectory=Gradata - # or with pipx (recommended): - pipx install git+https://github.com/Gradata/gradata.git#subdirectory=Gradata - ``` - -## License - -Apache-2.0. See [LICENSE](LICENSE). +The previous README is preserved at [README.legacy.md](README.legacy.md). diff --git a/hooks/lib/telemetry.js b/hooks/lib/telemetry.js new file mode 100644 index 0000000..6745efa --- /dev/null +++ b/hooks/lib/telemetry.js @@ -0,0 +1,139 @@ +#!/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 determineCohort(installId) { + if (!installId || typeof installId !== 'string' || installId.length < 2) return 'control'; + const hash = crypto.createHash('sha256').update(installId).digest('hex'); + return hash[0] < '8' ? 'control' : 'treatment'; +} + +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(), + }); +} + +async function sendTelemetryEvent(event, payload = {}) { + if (!telemetryEnabled()) return false; + const userId = getAnonymousUserId(); + if (!userId) return false; + if (typeof event !== 'string' || !event.trim()) return false; + + return postJson(TELEMETRY_ENDPOINT, { + event, + user_id: userId, + ts: new Date().toISOString(), + plugin_version: getPluginVersion(), + ...payload, + }); +} + +module.exports = { + telemetryEnabled, + ensureInstallId, + getAnonymousUserId, + determineCohort, + sendTelemetryMetric, + sendTelemetryEvent, +}; diff --git a/hooks/post-edit.js b/hooks/post-edit.js index d46ff3f..80d7886 100644 --- a/hooks/post-edit.js +++ b/hooks/post-edit.js @@ -1,6 +1,13 @@ #!/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'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const SENTINEL = path.join(os.homedir(), '.gradata', 'first-notification-sent'); + (async () => { try { const eventData = readHookInput(); @@ -13,9 +20,26 @@ const { readHookInput, WRITE_TOOLS } = require('./lib/hook-input.js'); const sessionId = eventData.session_id || ''; if (!oldStr && !newStr) process.exit(0); if (oldStr === newStr) process.exit(0); - await callDaemon('/correct', { + + // Call daemon and inspect response for first-correction signal (GRA-1202) + const result = await callDaemon('/correct', { old_string: oldStr, new_string: newStr, file_path: filePath, session_id: sessionId, }, 1000); + + await sendTelemetryMetric('corrections_captured', 1); + + // First-correction notification: only on the very first lesson creation + if (result && result.lesson_created && !fs.existsSync(SENTINEL)) { + try { + fs.mkdirSync(path.dirname(SENTINEL), { recursive: true }); + fs.writeFileSync(SENTINEL, ''); + process.stderr.write( + '[Gradata] 1 correction captured. lesson created. View: gradata lessons --recent\n', + ); + } catch { + // Best-effort — never block editing over notification state + } + } } 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..93df7da 100644 --- a/hooks/session-stop.js +++ b/hooks/session-stop.js @@ -1,12 +1,21 @@ #!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const os = require('os'); const { callDaemon } = require('./lib/daemon-client.js'); const { readHookInput } = require('./lib/hook-input.js'); +const { ensureInstallId, determineCohort, sendTelemetryMetric, sendTelemetryEvent } = require('./lib/telemetry.js'); + +const GRADATA_HOME = process.env.GRADATA_HOME || path.join(os.homedir(), '.gradata'); +const FIRST_RULE_FLAG = path.join(GRADATA_HOME, '.first_rule_injected'); + (async () => { try { const eventData = readHookInput(); const sessionId = eventData.session_id || ''; const endResult = await callDaemon('/end-session', { session_id: sessionId }, 5000); + let rulesWereActive = false; if (endResult) { const c = endResult.corrections_captured || 0; const i = endResult.instructions_extracted || 0; @@ -14,6 +23,26 @@ 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); + // Consider rules active if we captured corrections or graduated lessons this session + rulesWereActive = (c > 0 || g > 0); + } + + // Emit first_rule_injected once per install on the first session where rules were active + if (rulesWereActive && !fs.existsSync(FIRST_RULE_FLAG)) { + try { + const installId = ensureInstallId(); + const cohort = determineCohort(installId); + await sendTelemetryEvent('first_rule_injected', { + install_id: installId, + cohort, + }); + // Write flag file to prevent re-emission + fs.mkdirSync(path.dirname(FIRST_RULE_FLAG), { recursive: true }); + fs.writeFileSync(FIRST_RULE_FLAG, `${new Date().toISOString()}\n`, { mode: 0o600 }); + } catch (e) { + // Best-effort — never block on telemetry or flag write failure + } } const maintainResult = await callDaemon('/maintain', { tasks: ['manifest', 'patterns'] }, 10000); diff --git a/setup/install.js b/setup/install.js index 4aeed4a..a7beb55 100644 --- a/setup/install.js +++ b/setup/install.js @@ -116,6 +116,108 @@ async function ask(question) { return new Promise(resolve => rl.question(question, ans => { rl.close(); resolve(ans); })); } +// --- Starter brain seeding --------------------------------------------------- + +// Cohort assignment: deterministic 50/50 split via hash(installId)[0]. +// Treatment cohort gets starter-brain seed; control does not. +function seedStarterBrainIfTreatment() { + const telemetry = require('../hooks/lib/telemetry.js'); + const installId = telemetry.ensureInstallId(); + const cohort = telemetry.determineCohort(installId); + if (cohort !== 'treatment') { + console.log(`Cohort: ${cohort} — skipping starter brain seed.`); + return; + } + console.log(`Cohort: ${cohort} — seeding starter brain.`); + 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}`); +} + +// 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' + } +]; + // --- AGENTS.md patching ----------------------------------------------------- const BEGIN_MARKER = ''; @@ -277,6 +379,22 @@ async function main() { console.log(`AGENTS.md patch skipped: ${e.message}`); } + // Seed starter brain rules (cohort-based: treatment only) + seedStarterBrainIfTreatment(); + + // Emit install_completed telemetry event + try { + const telemetry = require('../hooks/lib/telemetry.js'); + const installId = telemetry.ensureInstallId(); + const cohort = telemetry.determineCohort(installId); + await telemetry.sendTelemetryEvent('install_completed', { + install_id: installId, + cohort, + }); + } catch (e) { + // Best-effort — never block setup on telemetry failure + } + 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..5aca6a8 --- /dev/null +++ b/tests/telemetry.test.js @@ -0,0 +1,60 @@ +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')]; +}); + +test('determineCohort returns control or treatment deterministically', () => { + delete require.cache[require.resolve('../hooks/lib/telemetry.js')]; + const { determineCohort } = require('../hooks/lib/telemetry.js'); + // Same input always returns same result + const a = determineCohort('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'); + const b = determineCohort('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'); + assert.strictEqual(a, b); + assert.ok(a === 'control' || a === 'treatment', 'must be control or treatment'); + delete require.cache[require.resolve('../hooks/lib/telemetry.js')]; +}); + +test('determineCohort returns control for empty/bad input', () => { + delete require.cache[require.resolve('../hooks/lib/telemetry.js')]; + const { determineCohort } = require('../hooks/lib/telemetry.js'); + assert.strictEqual(determineCohort(''), 'control'); + assert.strictEqual(determineCohort('x'), 'control'); + assert.strictEqual(determineCohort(null), 'control'); + assert.strictEqual(determineCohort(undefined), 'control'); + delete require.cache[require.resolve('../hooks/lib/telemetry.js')]; +});