drift-ai 0.1.0

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/README.md ADDED
@@ -0,0 +1,145 @@
1
+ # drift
2
+
3
+ > Track how your code drifts from the plan during AI coding sessions
4
+
5
+ When you vibe code with AI (Cursor, Copilot, Claude, etc.), the implementation often drifts from the original ticket. `drift` captures what actually happened and keeps context for the next session.
6
+
7
+ ## The Problem
8
+
9
+ 1. PO writes PRD → creates Jira tickets
10
+ 2. Developer takes ticket, vibes with AI
11
+ 3. AI changes things — sometimes a lot
12
+ 4. Ticket still says the old plan
13
+ 5. Next task has wrong context
14
+ 6. AI doesn't know what actually happened
15
+
16
+ ## The Solution
17
+
18
+ ```bash
19
+ drift start PROJ-123 # Start session, link to ticket
20
+ # ... vibe code with your AI ...
21
+ drift sync # AI summarizes what happened, updates CONTEXT.md
22
+ ```
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ npm install -g drift-ai
28
+ ```
29
+
30
+ Or from source:
31
+
32
+ ```bash
33
+ git clone https://github.com/mersall/drift-ai
34
+ cd drift-ai
35
+ npm install
36
+ npm run build
37
+ npm link
38
+ ```
39
+
40
+ ## Setup
41
+
42
+ ```bash
43
+ # In your project repo
44
+ drift init
45
+
46
+ # Get free API key from https://console.groq.com
47
+ drift config --groq-key gsk_...
48
+
49
+ # (Optional) Configure Jira
50
+ drift config --jira-url https://yourteam.atlassian.net \
51
+ --jira-email you@email.com \
52
+ --jira-token your-api-token
53
+ ```
54
+
55
+ ## Usage
56
+
57
+ ### Start a session
58
+
59
+ ```bash
60
+ drift start # Start without ticket
61
+ drift start PROJ-123 # Link to Jira ticket
62
+ drift start PROJ-123 -m "Adding auth flow" # With plan description
63
+ ```
64
+
65
+ ### Check status
66
+
67
+ ```bash
68
+ drift status # See what's changed since session start
69
+ ```
70
+
71
+ ### End session & sync
72
+
73
+ ```bash
74
+ drift sync # Generate summary, update CONTEXT.md, post to Jira
75
+ drift sync --no-push # Skip Jira posting
76
+ drift sync -m "Also refactored the utils" # Add notes
77
+ ```
78
+
79
+ ### View history
80
+
81
+ ```bash
82
+ drift log # Last 5 sessions
83
+ drift log -n 10 # Last 10 sessions
84
+ ```
85
+
86
+ ## CONTEXT.md
87
+
88
+ After each sync, `drift` updates `CONTEXT.md` in your repo:
89
+
90
+ ```markdown
91
+ # Project Context
92
+
93
+ ## Session: 2024-02-03 (PROJ-123)
94
+
95
+ **What happened:**
96
+ Implemented JWT authentication with refresh tokens. Switched from localStorage
97
+ to httpOnly cookies for better security.
98
+
99
+ **Decisions made:**
100
+ - Using cookies over localStorage for token storage
101
+ - Added rate limiting on auth endpoints (wasn't in spec)
102
+ - Skipped "remember me" — needs product decision
103
+
104
+ **Drift from plan:**
105
+ - Original spec said localStorage, we changed to cookies
106
+ - Added refresh token rotation (not in original ticket)
107
+
108
+ **For next session:**
109
+ - Auth context lives in /src/auth/
110
+ - Token refresh is handled in axios interceptor
111
+ - Still need: password reset flow
112
+ ```
113
+
114
+ This file becomes context for your next AI session — paste it or let your AI read it.
115
+
116
+ ## Environment Variables
117
+
118
+ Instead of `drift config`, you can use environment variables:
119
+
120
+ ```bash
121
+ export GROQ_API_KEY=gsk_...
122
+ export JIRA_URL=https://yourteam.atlassian.net
123
+ export JIRA_EMAIL=you@email.com
124
+ export JIRA_TOKEN=your-api-token
125
+ ```
126
+
127
+ ## How It Works
128
+
129
+ 1. `drift start` saves the current git SHA
130
+ 2. You code with your AI assistant
131
+ 3. `drift sync` gets the git diff since start
132
+ 4. Groq (Llama 3.3 70B) analyzes what changed and generates a structured summary
133
+ 5. CONTEXT.md is updated with the session info
134
+ 6. (Optional) Summary is posted as a comment on the Jira ticket
135
+
136
+ ## Why Groq?
137
+
138
+ - **100% free** — no credit card needed
139
+ - **Fast** — responses in <1 second
140
+ - **Good quality** — Llama 3.3 70B is excellent for code analysis
141
+ - **No subscription required** — just sign up and get a key
142
+
143
+ ## License
144
+
145
+ MIT
@@ -0,0 +1,9 @@
1
+ interface ConfigOptions {
2
+ jiraUrl?: string;
3
+ jiraEmail?: string;
4
+ jiraToken?: string;
5
+ groqKey?: string;
6
+ show?: boolean;
7
+ }
8
+ export declare function configCommand(options?: ConfigOptions): Promise<void>;
9
+ export {};
@@ -0,0 +1,54 @@
1
+ import chalk from 'chalk';
2
+ import { setGroqKey, setJiraConfig, getGroqKey, getJiraConfig } from '../lib/config.js';
3
+ export async function configCommand(options) {
4
+ // If no options, show config
5
+ if (!options || Object.keys(options).length === 0 || options.show) {
6
+ console.log(chalk.bold('drift configuration'));
7
+ console.log(chalk.dim('─'.repeat(40)));
8
+ console.log();
9
+ const groqKey = getGroqKey();
10
+ const jira = getJiraConfig();
11
+ console.log(chalk.bold('Groq API (free):'));
12
+ if (groqKey) {
13
+ console.log(chalk.green(' ✓ Configured') + chalk.dim(` (***${groqKey.slice(-4)})`));
14
+ }
15
+ else {
16
+ console.log(chalk.red(' ✗ Not configured'));
17
+ console.log(chalk.dim(' Get free key: https://console.groq.com'));
18
+ console.log(chalk.dim(' Set with: drift config --groq-key <key>'));
19
+ }
20
+ console.log();
21
+ console.log(chalk.bold('Jira:'));
22
+ if (jira.url && jira.email && jira.token) {
23
+ console.log(chalk.green(' ✓ Configured'));
24
+ console.log(chalk.dim(` URL: ${jira.url}`));
25
+ console.log(chalk.dim(` Email: ${jira.email}`));
26
+ }
27
+ else {
28
+ console.log(chalk.yellow(' ○ Not configured') + chalk.dim(' (optional)'));
29
+ console.log(chalk.dim(' Set with: drift config --jira-url <url> --jira-email <email> --jira-token <token>'));
30
+ }
31
+ console.log();
32
+ return;
33
+ }
34
+ let updated = false;
35
+ if (options.groqKey) {
36
+ setGroqKey(options.groqKey);
37
+ console.log(chalk.green('✓ Groq API key saved'));
38
+ updated = true;
39
+ }
40
+ if (options.jiraUrl || options.jiraEmail || options.jiraToken) {
41
+ setJiraConfig(options.jiraUrl, options.jiraEmail, options.jiraToken);
42
+ if (options.jiraUrl)
43
+ console.log(chalk.green('✓ Jira URL saved'));
44
+ if (options.jiraEmail)
45
+ console.log(chalk.green('✓ Jira email saved'));
46
+ if (options.jiraToken)
47
+ console.log(chalk.green('✓ Jira token saved'));
48
+ updated = true;
49
+ }
50
+ if (updated) {
51
+ console.log();
52
+ console.log(chalk.dim('Run `drift config --show` to see current configuration.'));
53
+ }
54
+ }
@@ -0,0 +1 @@
1
+ export declare function initCommand(): Promise<void>;
@@ -0,0 +1,56 @@
1
+ import chalk from 'chalk';
2
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, appendFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { isGitRepo } from '../lib/git.js';
5
+ import { getLocalConfigPath, saveLocalConfig } from '../lib/config.js';
6
+ export async function initCommand() {
7
+ // Check if we're in a git repo
8
+ if (!(await isGitRepo())) {
9
+ console.log(chalk.red('Error: Not a git repository.'));
10
+ console.log(chalk.dim('Run this command from the root of a git repository.'));
11
+ process.exit(1);
12
+ }
13
+ const driftDir = join(process.cwd(), '.drift');
14
+ const configPath = getLocalConfigPath();
15
+ // Check if already initialized
16
+ if (existsSync(configPath)) {
17
+ console.log(chalk.yellow('drift is already initialized in this repository.'));
18
+ return;
19
+ }
20
+ // Create .drift directory
21
+ if (!existsSync(driftDir)) {
22
+ mkdirSync(driftDir, { recursive: true });
23
+ }
24
+ // Create initial config
25
+ saveLocalConfig({
26
+ initialized: true,
27
+ sessions: [],
28
+ });
29
+ // Create .drift/sessions.json for history
30
+ const sessionsPath = join(driftDir, 'sessions.json');
31
+ writeFileSync(sessionsPath, '[]');
32
+ // Add .drift to .gitignore if not already there
33
+ const gitignorePath = join(process.cwd(), '.gitignore');
34
+ if (existsSync(gitignorePath)) {
35
+ const gitignore = readFileSync(gitignorePath, 'utf-8');
36
+ if (!gitignore.includes('.drift')) {
37
+ appendFileSync(gitignorePath, '\n# drift session data\n.drift/\n');
38
+ console.log(chalk.dim('Added .drift/ to .gitignore'));
39
+ }
40
+ }
41
+ else {
42
+ writeFileSync(gitignorePath, '# drift session data\n.drift/\n');
43
+ console.log(chalk.dim('Created .gitignore with .drift/'));
44
+ }
45
+ console.log(chalk.green('✓ drift initialized'));
46
+ console.log();
47
+ console.log('Next steps:');
48
+ console.log(chalk.dim(' 1. Get free Groq API key:'));
49
+ console.log(chalk.cyan(' https://console.groq.com'));
50
+ console.log(chalk.dim(' 2. Configure the key:'));
51
+ console.log(chalk.cyan(' drift config --groq-key <your-key>'));
52
+ console.log(chalk.dim(' 3. (Optional) Configure Jira:'));
53
+ console.log(chalk.cyan(' drift config --jira-url https://yourteam.atlassian.net --jira-email you@email.com --jira-token <token>'));
54
+ console.log(chalk.dim(' 4. Start a coding session:'));
55
+ console.log(chalk.cyan(' drift start PROJ-123'));
56
+ }
@@ -0,0 +1,5 @@
1
+ interface LogOptions {
2
+ number?: string;
3
+ }
4
+ export declare function logCommand(options?: LogOptions): Promise<void>;
5
+ export {};
@@ -0,0 +1,44 @@
1
+ import chalk from 'chalk';
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { getLocalConfig } from '../lib/config.js';
5
+ import { isGitRepo } from '../lib/git.js';
6
+ export async function logCommand(options) {
7
+ if (!(await isGitRepo())) {
8
+ console.log(chalk.red('Error: Not a git repository.'));
9
+ process.exit(1);
10
+ }
11
+ const config = getLocalConfig();
12
+ if (!config.initialized) {
13
+ console.log(chalk.red('Error: drift not initialized. Run: drift init'));
14
+ process.exit(1);
15
+ }
16
+ const sessionsPath = join(process.cwd(), '.drift', 'sessions.json');
17
+ if (!existsSync(sessionsPath)) {
18
+ console.log(chalk.dim('No sessions recorded yet.'));
19
+ return;
20
+ }
21
+ const sessions = JSON.parse(readFileSync(sessionsPath, 'utf-8'));
22
+ const count = parseInt(options?.number || '5', 10);
23
+ if (sessions.length === 0) {
24
+ console.log(chalk.dim('No sessions recorded yet.'));
25
+ return;
26
+ }
27
+ console.log(chalk.bold('drift session history'));
28
+ console.log(chalk.dim('─'.repeat(50)));
29
+ console.log();
30
+ sessions.slice(0, count).forEach((session, i) => {
31
+ const date = new Date(session.date).toLocaleString();
32
+ const ticket = session.ticket ? chalk.cyan(session.ticket) : chalk.dim('no ticket');
33
+ console.log(chalk.bold(`${i + 1}. ${date}`));
34
+ console.log(chalk.dim(' Ticket: ') + ticket);
35
+ console.log(chalk.dim(' SHA: ') + session.startSha.slice(0, 7));
36
+ if (session.summary) {
37
+ console.log(chalk.dim(' Summary: ') + session.summary.slice(0, 80) + (session.summary.length > 80 ? '...' : ''));
38
+ }
39
+ console.log();
40
+ });
41
+ if (sessions.length > count) {
42
+ console.log(chalk.dim(`Showing ${count} of ${sessions.length} sessions. Use -n to see more.`));
43
+ }
44
+ }
@@ -0,0 +1,5 @@
1
+ interface StartOptions {
2
+ message?: string;
3
+ }
4
+ export declare function startCommand(ticket?: string, options?: StartOptions): Promise<void>;
5
+ export {};
@@ -0,0 +1,73 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import { getLocalConfig, setActiveSession, getActiveSession } from '../lib/config.js';
4
+ import { getCurrentSha, getCurrentBranch, isGitRepo } from '../lib/git.js';
5
+ import { getTicket, extractTicketKey, isJiraConfigured } from '../integrations/jira.js';
6
+ export async function startCommand(ticket, options) {
7
+ // Check if initialized
8
+ if (!(await isGitRepo())) {
9
+ console.log(chalk.red('Error: Not a git repository.'));
10
+ process.exit(1);
11
+ }
12
+ const config = getLocalConfig();
13
+ if (!config.initialized) {
14
+ console.log(chalk.red('Error: drift not initialized. Run: drift init'));
15
+ process.exit(1);
16
+ }
17
+ // Check for active session
18
+ const activeSession = getActiveSession();
19
+ if (activeSession) {
20
+ console.log(chalk.yellow('Warning: A session is already active.'));
21
+ console.log(chalk.dim(`Started at: ${activeSession.startedAt}`));
22
+ if (activeSession.ticket) {
23
+ console.log(chalk.dim(`Ticket: ${activeSession.ticket}`));
24
+ }
25
+ console.log();
26
+ console.log('Run ' + chalk.cyan('drift sync') + ' to end the current session first.');
27
+ return;
28
+ }
29
+ const spinner = ora('Starting session...').start();
30
+ try {
31
+ const [sha, branch] = await Promise.all([getCurrentSha(), getCurrentBranch()]);
32
+ let ticketKey;
33
+ let ticketContext;
34
+ // Try to extract and fetch ticket info
35
+ if (ticket) {
36
+ ticketKey = extractTicketKey(ticket) || ticket;
37
+ if (isJiraConfigured()) {
38
+ try {
39
+ const ticketInfo = await getTicket(ticketKey);
40
+ ticketContext = `${ticketInfo.summary}\n${ticketInfo.description}`;
41
+ spinner.text = `Linked to ${ticketKey}: ${ticketInfo.summary}`;
42
+ }
43
+ catch (e) {
44
+ spinner.warn(`Could not fetch ticket ${ticketKey} from Jira`);
45
+ }
46
+ }
47
+ }
48
+ const session = {
49
+ startedAt: new Date().toISOString(),
50
+ startSha: sha,
51
+ ticket: ticketKey,
52
+ plannedWork: options?.message || ticketContext,
53
+ };
54
+ setActiveSession(session);
55
+ spinner.succeed(chalk.green('Session started'));
56
+ console.log();
57
+ console.log(chalk.dim(`Branch: ${branch}`));
58
+ console.log(chalk.dim(`Starting SHA: ${sha.slice(0, 7)}`));
59
+ if (ticketKey) {
60
+ console.log(chalk.dim(`Ticket: ${ticketKey}`));
61
+ }
62
+ if (options?.message) {
63
+ console.log(chalk.dim(`Plan: ${options.message}`));
64
+ }
65
+ console.log();
66
+ console.log('Go vibe with your AI. When done, run: ' + chalk.cyan('drift sync'));
67
+ }
68
+ catch (error) {
69
+ spinner.fail('Failed to start session');
70
+ console.error(error);
71
+ process.exit(1);
72
+ }
73
+ }
@@ -0,0 +1 @@
1
+ export declare function statusCommand(): Promise<void>;
@@ -0,0 +1,70 @@
1
+ import chalk from 'chalk';
2
+ import { getLocalConfig, getActiveSession } from '../lib/config.js';
3
+ import { getStatusSince, isGitRepo, getCurrentSha, getCurrentBranch } from '../lib/git.js';
4
+ export async function statusCommand() {
5
+ if (!(await isGitRepo())) {
6
+ console.log(chalk.red('Error: Not a git repository.'));
7
+ process.exit(1);
8
+ }
9
+ const config = getLocalConfig();
10
+ if (!config.initialized) {
11
+ console.log(chalk.red('Error: drift not initialized. Run: drift init'));
12
+ process.exit(1);
13
+ }
14
+ const session = getActiveSession();
15
+ console.log(chalk.bold('drift status'));
16
+ console.log(chalk.dim('─'.repeat(40)));
17
+ console.log();
18
+ if (!session) {
19
+ const [sha, branch] = await Promise.all([getCurrentSha(), getCurrentBranch()]);
20
+ console.log(chalk.dim('No active session.'));
21
+ console.log();
22
+ console.log(chalk.dim(`Branch: ${branch}`));
23
+ console.log(chalk.dim(`HEAD: ${sha.slice(0, 7)}`));
24
+ console.log();
25
+ console.log('Start a session with: ' + chalk.cyan('drift start [ticket]'));
26
+ return;
27
+ }
28
+ const status = await getStatusSince(session.startSha);
29
+ console.log(chalk.green('● Session active'));
30
+ console.log();
31
+ console.log(chalk.dim('Started: ') + new Date(session.startedAt).toLocaleString());
32
+ if (session.ticket) {
33
+ console.log(chalk.dim('Ticket: ') + session.ticket);
34
+ }
35
+ if (session.plannedWork) {
36
+ console.log(chalk.dim('Plan: ') + session.plannedWork.slice(0, 100) + (session.plannedWork.length > 100 ? '...' : ''));
37
+ }
38
+ console.log();
39
+ console.log(chalk.dim('Branch: ') + status.branch);
40
+ console.log(chalk.dim('Started at: ') + session.startSha.slice(0, 7));
41
+ console.log(chalk.dim('Current: ') + status.currentSha.slice(0, 7));
42
+ console.log();
43
+ if (status.changedFiles.length === 0 && status.commits.length === 0) {
44
+ console.log(chalk.yellow('No changes yet.'));
45
+ }
46
+ else {
47
+ console.log(chalk.bold(`Changes since session start:`));
48
+ console.log();
49
+ if (status.commits.length > 0) {
50
+ console.log(chalk.dim('Commits:'));
51
+ status.commits.slice(0, 10).forEach((c) => console.log(chalk.dim(' ') + c));
52
+ if (status.commits.length > 10) {
53
+ console.log(chalk.dim(` ... and ${status.commits.length - 10} more`));
54
+ }
55
+ console.log();
56
+ }
57
+ console.log(chalk.dim('Files changed:'));
58
+ status.changedFiles.slice(0, 15).forEach((f) => console.log(chalk.dim(' ') + f));
59
+ if (status.changedFiles.length > 15) {
60
+ console.log(chalk.dim(` ... and ${status.changedFiles.length - 15} more`));
61
+ }
62
+ console.log();
63
+ if (status.diffStat) {
64
+ console.log(chalk.dim('Stats:'));
65
+ console.log(status.diffStat.split('\n').slice(-3).join('\n'));
66
+ }
67
+ }
68
+ console.log();
69
+ console.log('When done, run: ' + chalk.cyan('drift sync'));
70
+ }
@@ -0,0 +1,6 @@
1
+ interface SyncOptions {
2
+ message?: string;
3
+ push?: boolean;
4
+ }
5
+ export declare function syncCommand(options?: SyncOptions): Promise<void>;
6
+ export {};
@@ -0,0 +1,122 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { getLocalConfig, clearActiveSession, getActiveSession } from '../lib/config.js';
6
+ import { getDiffSince, getDiffStatSince, getCommitsSince, getChangedFiles, isGitRepo } from '../lib/git.js';
7
+ import { summarizeSession } from '../lib/ai.js';
8
+ import { updateContext } from '../lib/context.js';
9
+ import { addComment, isJiraConfigured } from '../integrations/jira.js';
10
+ function getSessionsPath() {
11
+ return join(process.cwd(), '.drift', 'sessions.json');
12
+ }
13
+ function saveToHistory(entry) {
14
+ const path = getSessionsPath();
15
+ let history = [];
16
+ if (existsSync(path)) {
17
+ history = JSON.parse(readFileSync(path, 'utf-8'));
18
+ }
19
+ history.unshift(entry);
20
+ // Keep last 50 sessions
21
+ history = history.slice(0, 50);
22
+ writeFileSync(path, JSON.stringify(history, null, 2));
23
+ }
24
+ export async function syncCommand(options) {
25
+ if (!(await isGitRepo())) {
26
+ console.log(chalk.red('Error: Not a git repository.'));
27
+ process.exit(1);
28
+ }
29
+ const config = getLocalConfig();
30
+ if (!config.initialized) {
31
+ console.log(chalk.red('Error: drift not initialized. Run: drift init'));
32
+ process.exit(1);
33
+ }
34
+ const session = getActiveSession();
35
+ if (!session) {
36
+ console.log(chalk.yellow('No active session.'));
37
+ console.log('Start one with: ' + chalk.cyan('drift start [ticket]'));
38
+ return;
39
+ }
40
+ const spinner = ora('Analyzing changes...').start();
41
+ try {
42
+ // Get all the git info
43
+ const [diff, diffStat, commits, changedFiles] = await Promise.all([
44
+ getDiffSince(session.startSha),
45
+ getDiffStatSince(session.startSha),
46
+ getCommitsSince(session.startSha),
47
+ getChangedFiles(session.startSha),
48
+ ]);
49
+ if (changedFiles.length === 0 && commits.length === 0) {
50
+ spinner.warn('No changes detected since session start.');
51
+ const cleared = clearActiveSession();
52
+ console.log(chalk.dim('Session ended without changes.'));
53
+ return;
54
+ }
55
+ spinner.text = 'Generating summary with AI...';
56
+ // Generate AI summary
57
+ const summary = await summarizeSession(diff, diffStat, commits, changedFiles, session.plannedWork, undefined // TODO: fetch ticket context if available
58
+ );
59
+ spinner.text = 'Updating CONTEXT.md...';
60
+ // Update CONTEXT.md
61
+ updateContext({
62
+ date: new Date().toISOString().split('T')[0],
63
+ ticket: session.ticket,
64
+ summary,
65
+ });
66
+ // Save to history
67
+ saveToHistory({
68
+ date: session.startedAt,
69
+ ticket: session.ticket,
70
+ startSha: session.startSha,
71
+ summary: summary.whatHappened,
72
+ });
73
+ // Post to Jira if configured and ticket exists
74
+ if (options?.push !== false && session.ticket && isJiraConfigured()) {
75
+ spinner.text = `Posting to Jira (${session.ticket})...`;
76
+ try {
77
+ await addComment(session.ticket, `🤖 Drift Session Summary:\n\n${summary.ticketComment}`);
78
+ spinner.succeed('Session synced & posted to Jira');
79
+ }
80
+ catch (e) {
81
+ spinner.warn('Session synced, but failed to post to Jira');
82
+ console.error(chalk.dim(String(e)));
83
+ }
84
+ }
85
+ else {
86
+ spinner.succeed('Session synced');
87
+ }
88
+ // Clear the active session
89
+ clearActiveSession();
90
+ // Print summary
91
+ console.log();
92
+ console.log(chalk.bold('📝 Session Summary'));
93
+ console.log(chalk.dim('─'.repeat(50)));
94
+ console.log();
95
+ console.log(chalk.bold('What happened:'));
96
+ console.log(summary.whatHappened);
97
+ console.log();
98
+ if (summary.decisions.length > 0) {
99
+ console.log(chalk.bold('Decisions:'));
100
+ summary.decisions.forEach((d) => console.log(chalk.dim(' • ') + d));
101
+ console.log();
102
+ }
103
+ if (summary.drift.length > 0) {
104
+ console.log(chalk.bold('Drift from plan:'));
105
+ summary.drift.forEach((d) => console.log(chalk.yellow(' ⚡ ') + d));
106
+ console.log();
107
+ }
108
+ if (summary.forNextSession.length > 0) {
109
+ console.log(chalk.bold('For next session:'));
110
+ summary.forNextSession.forEach((d) => console.log(chalk.cyan(' → ') + d));
111
+ console.log();
112
+ }
113
+ console.log(chalk.dim('─'.repeat(50)));
114
+ console.log(chalk.dim(`Files changed: ${changedFiles.length} | Commits: ${commits.length}`));
115
+ console.log(chalk.green('✓ CONTEXT.md updated'));
116
+ }
117
+ catch (error) {
118
+ spinner.fail('Failed to sync session');
119
+ console.error(error);
120
+ process.exit(1);
121
+ }
122
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { initCommand } from './commands/init.js';
4
+ import { startCommand } from './commands/start.js';
5
+ import { syncCommand } from './commands/sync.js';
6
+ import { statusCommand } from './commands/status.js';
7
+ import { logCommand } from './commands/log.js';
8
+ import { configCommand } from './commands/config.js';
9
+ const program = new Command();
10
+ program
11
+ .name('drift')
12
+ .description('Track how your code drifts from the plan during AI coding sessions')
13
+ .version('0.1.0');
14
+ program
15
+ .command('init')
16
+ .description('Initialize drift in current repository')
17
+ .action(initCommand);
18
+ program
19
+ .command('start [ticket]')
20
+ .description('Start a coding session, optionally linked to a ticket')
21
+ .option('-m, --message <message>', 'What you plan to work on')
22
+ .action(startCommand);
23
+ program
24
+ .command('sync')
25
+ .description('End session, generate summary, update context')
26
+ .option('-m, --message <message>', 'Additional notes about the session')
27
+ .option('--no-push', 'Skip pushing to Jira')
28
+ .action(syncCommand);
29
+ program
30
+ .command('status')
31
+ .description('Show what changed since last sync')
32
+ .action(statusCommand);
33
+ program
34
+ .command('log')
35
+ .description('View history of coding sessions')
36
+ .option('-n, --number <count>', 'Number of sessions to show', '5')
37
+ .action(logCommand);
38
+ program
39
+ .command('config')
40
+ .description('Configure drift settings')
41
+ .option('--jira-url <url>', 'Jira instance URL')
42
+ .option('--jira-email <email>', 'Jira account email')
43
+ .option('--jira-token <token>', 'Jira API token')
44
+ .option('--groq-key <key>', 'Groq API key (free at console.groq.com)')
45
+ .option('--show', 'Show current configuration')
46
+ .action(configCommand);
47
+ program.parse();
@@ -0,0 +1,10 @@
1
+ export interface JiraTicket {
2
+ key: string;
3
+ summary: string;
4
+ description: string;
5
+ status: string;
6
+ }
7
+ export declare function getTicket(ticketKey: string): Promise<JiraTicket>;
8
+ export declare function addComment(ticketKey: string, comment: string): Promise<void>;
9
+ export declare function isJiraConfigured(): boolean;
10
+ export declare function extractTicketKey(input: string): string | null;
@@ -0,0 +1,74 @@
1
+ import { getJiraConfig } from '../lib/config.js';
2
+ function getAuthHeader() {
3
+ const { email, token } = getJiraConfig();
4
+ if (!email || !token) {
5
+ throw new Error('Jira not configured. Run: drift config --jira-url <url> --jira-email <email> --jira-token <token>');
6
+ }
7
+ return 'Basic ' + Buffer.from(`${email}:${token}`).toString('base64');
8
+ }
9
+ function getBaseUrl() {
10
+ const { url } = getJiraConfig();
11
+ if (!url) {
12
+ throw new Error('Jira URL not configured. Run: drift config --jira-url <url>');
13
+ }
14
+ return url.replace(/\/$/, '');
15
+ }
16
+ export async function getTicket(ticketKey) {
17
+ const baseUrl = getBaseUrl();
18
+ const response = await fetch(`${baseUrl}/rest/api/3/issue/${ticketKey}`, {
19
+ headers: {
20
+ Authorization: getAuthHeader(),
21
+ 'Content-Type': 'application/json',
22
+ },
23
+ });
24
+ if (!response.ok) {
25
+ throw new Error(`Failed to fetch ticket ${ticketKey}: ${response.status}`);
26
+ }
27
+ const data = await response.json();
28
+ return {
29
+ key: data.key,
30
+ summary: data.fields.summary,
31
+ description: data.fields.description?.content?.[0]?.content?.[0]?.text || '',
32
+ status: data.fields.status.name,
33
+ };
34
+ }
35
+ export async function addComment(ticketKey, comment) {
36
+ const baseUrl = getBaseUrl();
37
+ const response = await fetch(`${baseUrl}/rest/api/3/issue/${ticketKey}/comment`, {
38
+ method: 'POST',
39
+ headers: {
40
+ Authorization: getAuthHeader(),
41
+ 'Content-Type': 'application/json',
42
+ },
43
+ body: JSON.stringify({
44
+ body: {
45
+ type: 'doc',
46
+ version: 1,
47
+ content: [
48
+ {
49
+ type: 'paragraph',
50
+ content: [
51
+ {
52
+ type: 'text',
53
+ text: comment,
54
+ },
55
+ ],
56
+ },
57
+ ],
58
+ },
59
+ }),
60
+ });
61
+ if (!response.ok) {
62
+ const error = await response.text();
63
+ throw new Error(`Failed to add comment to ${ticketKey}: ${response.status} - ${error}`);
64
+ }
65
+ }
66
+ export function isJiraConfigured() {
67
+ const { url, email, token } = getJiraConfig();
68
+ return !!(url && email && token);
69
+ }
70
+ export function extractTicketKey(input) {
71
+ // Match common Jira ticket patterns: PROJ-123, ABC-1, etc.
72
+ const match = input.match(/[A-Z][A-Z0-9]+-\d+/i);
73
+ return match ? match[0].toUpperCase() : null;
74
+ }
@@ -0,0 +1,8 @@
1
+ export interface SessionSummary {
2
+ whatHappened: string;
3
+ decisions: string[];
4
+ drift: string[];
5
+ forNextSession: string[];
6
+ ticketComment: string;
7
+ }
8
+ export declare function summarizeSession(diff: string, diffStat: string, commits: string[], changedFiles: string[], plannedWork?: string, ticketContext?: string): Promise<SessionSummary>;
package/dist/lib/ai.js ADDED
@@ -0,0 +1,73 @@
1
+ import Groq from 'groq-sdk';
2
+ import { getGroqKey } from './config.js';
3
+ export async function summarizeSession(diff, diffStat, commits, changedFiles, plannedWork, ticketContext) {
4
+ const apiKey = getGroqKey();
5
+ if (!apiKey) {
6
+ throw new Error('Groq API key not configured. Run: drift config --groq-key <key>\nGet free key at: https://console.groq.com');
7
+ }
8
+ const client = new Groq({ apiKey });
9
+ const prompt = `You are analyzing a coding session. Based on the git diff and context, create a summary.
10
+
11
+ ${plannedWork ? `## What was planned\n${plannedWork}\n` : ''}
12
+ ${ticketContext ? `## Ticket context\n${ticketContext}\n` : ''}
13
+
14
+ ## Files changed
15
+ ${changedFiles.join('\n')}
16
+
17
+ ## Diff stats
18
+ ${diffStat}
19
+
20
+ ## Commits
21
+ ${commits.length > 0 ? commits.join('\n') : 'No commits yet (uncommitted changes)'}
22
+
23
+ ## Diff (truncated to 8000 chars)
24
+ ${diff.slice(0, 8000)}
25
+
26
+ ---
27
+
28
+ Analyze this session and respond in JSON format only (no markdown, no code blocks):
29
+ {
30
+ "whatHappened": "2-3 sentence summary of what was actually implemented",
31
+ "decisions": ["list of technical decisions made", "each as a short bullet"],
32
+ "drift": ["ways the implementation differed from the plan", "empty array if no drift or no plan provided"],
33
+ "forNextSession": ["important context for continuing this work", "where to find key code", "what's left to do"],
34
+ "ticketComment": "A concise comment suitable for posting on the Jira ticket (2-4 sentences)"
35
+ }
36
+
37
+ Be specific about file paths, function names, and technical details. Focus on what would help a developer (or AI) continue this work tomorrow.
38
+ Respond with ONLY valid JSON, no extra text.`;
39
+ const response = await client.chat.completions.create({
40
+ model: 'llama-3.3-70b-versatile',
41
+ messages: [{ role: 'user', content: prompt }],
42
+ max_tokens: 1500,
43
+ temperature: 0.3,
44
+ });
45
+ const content = response.choices[0]?.message?.content;
46
+ if (!content) {
47
+ throw new Error('Empty response from Groq');
48
+ }
49
+ try {
50
+ // Extract JSON from response (handle markdown code blocks if present)
51
+ let jsonStr = content;
52
+ const jsonMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
53
+ if (jsonMatch) {
54
+ jsonStr = jsonMatch[1];
55
+ }
56
+ // Also try to find JSON object directly
57
+ const objectMatch = jsonStr.match(/\{[\s\S]*\}/);
58
+ if (objectMatch) {
59
+ jsonStr = objectMatch[0];
60
+ }
61
+ return JSON.parse(jsonStr.trim());
62
+ }
63
+ catch {
64
+ // Fallback if JSON parsing fails
65
+ return {
66
+ whatHappened: content.slice(0, 500),
67
+ decisions: [],
68
+ drift: [],
69
+ forNextSession: [],
70
+ ticketComment: 'Session completed. See CONTEXT.md for details.',
71
+ };
72
+ }
73
+ }
@@ -0,0 +1,25 @@
1
+ export interface SessionState {
2
+ startedAt: string;
3
+ startSha: string;
4
+ ticket?: string;
5
+ plannedWork?: string;
6
+ }
7
+ export interface DriftConfig {
8
+ initialized: boolean;
9
+ sessions: SessionState[];
10
+ }
11
+ export declare function getLocalConfigPath(): string;
12
+ export declare function getLocalConfig(): DriftConfig;
13
+ export declare function saveLocalConfig(config: DriftConfig): void;
14
+ export declare function getActiveSession(): SessionState | null;
15
+ export declare function setActiveSession(session: SessionState | null): void;
16
+ export declare function clearActiveSession(): SessionState | null;
17
+ export declare function getGroqKey(): string | undefined;
18
+ export declare function setGroqKey(key: string): void;
19
+ export declare function getJiraConfig(): {
20
+ url?: string;
21
+ email?: string;
22
+ token?: string;
23
+ };
24
+ export declare function setJiraConfig(url?: string, email?: string, token?: string): void;
25
+ export declare function showConfig(): Record<string, unknown>;
@@ -0,0 +1,73 @@
1
+ import Conf from 'conf';
2
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ // Global config (API keys, Jira URL)
5
+ const globalConfig = new Conf({
6
+ projectName: 'drift',
7
+ schema: {
8
+ groqKey: { type: 'string' },
9
+ jiraUrl: { type: 'string' },
10
+ jiraEmail: { type: 'string' },
11
+ jiraToken: { type: 'string' },
12
+ },
13
+ });
14
+ // Local config (per-repo, stored in .drift/config.json)
15
+ export function getLocalConfigPath() {
16
+ return join(process.cwd(), '.drift', 'config.json');
17
+ }
18
+ export function getLocalConfig() {
19
+ const configPath = getLocalConfigPath();
20
+ if (existsSync(configPath)) {
21
+ return JSON.parse(readFileSync(configPath, 'utf-8'));
22
+ }
23
+ return { initialized: false, sessions: [] };
24
+ }
25
+ export function saveLocalConfig(config) {
26
+ const configPath = getLocalConfigPath();
27
+ writeFileSync(configPath, JSON.stringify(config, null, 2));
28
+ }
29
+ export function getActiveSession() {
30
+ const config = getLocalConfig();
31
+ return config.sessions.length > 0 ? config.sessions[config.sessions.length - 1] : null;
32
+ }
33
+ export function setActiveSession(session) {
34
+ const config = getLocalConfig();
35
+ if (session) {
36
+ config.sessions.push(session);
37
+ }
38
+ saveLocalConfig(config);
39
+ }
40
+ export function clearActiveSession() {
41
+ const config = getLocalConfig();
42
+ const session = config.sessions.pop() || null;
43
+ saveLocalConfig(config);
44
+ return session;
45
+ }
46
+ // Global config getters/setters
47
+ export function getGroqKey() {
48
+ return process.env.GROQ_API_KEY || globalConfig.get('groqKey');
49
+ }
50
+ export function setGroqKey(key) {
51
+ globalConfig.set('groqKey', key);
52
+ }
53
+ export function getJiraConfig() {
54
+ return {
55
+ url: process.env.JIRA_URL || globalConfig.get('jiraUrl'),
56
+ email: process.env.JIRA_EMAIL || globalConfig.get('jiraEmail'),
57
+ token: process.env.JIRA_TOKEN || globalConfig.get('jiraToken'),
58
+ };
59
+ }
60
+ export function setJiraConfig(url, email, token) {
61
+ if (url)
62
+ globalConfig.set('jiraUrl', url);
63
+ if (email)
64
+ globalConfig.set('jiraEmail', email);
65
+ if (token)
66
+ globalConfig.set('jiraToken', token);
67
+ }
68
+ export function showConfig() {
69
+ return {
70
+ groqKey: getGroqKey() ? '***' + (getGroqKey()?.slice(-4) || '') : undefined,
71
+ jira: getJiraConfig(),
72
+ };
73
+ }
@@ -0,0 +1,11 @@
1
+ import { SessionSummary } from './ai.js';
2
+ export interface SessionEntry {
3
+ date: string;
4
+ ticket?: string;
5
+ summary: SessionSummary;
6
+ }
7
+ export declare function getContextPath(): string;
8
+ export declare function contextExists(): boolean;
9
+ export declare function readContext(): string;
10
+ export declare function updateContext(entry: SessionEntry): void;
11
+ export declare function getLatestSessionContext(): string | null;
@@ -0,0 +1,74 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ const CONTEXT_FILE = 'CONTEXT.md';
4
+ const MAX_PREVIOUS_SESSIONS = 5;
5
+ export function getContextPath() {
6
+ return join(process.cwd(), CONTEXT_FILE);
7
+ }
8
+ export function contextExists() {
9
+ return existsSync(getContextPath());
10
+ }
11
+ export function readContext() {
12
+ const path = getContextPath();
13
+ if (existsSync(path)) {
14
+ return readFileSync(path, 'utf-8');
15
+ }
16
+ return '';
17
+ }
18
+ function formatSessionEntry(entry) {
19
+ let content = `## Session: ${entry.date}`;
20
+ if (entry.ticket) {
21
+ content += ` (${entry.ticket})`;
22
+ }
23
+ content += '\n\n';
24
+ content += `**What happened:**\n${entry.summary.whatHappened}\n\n`;
25
+ if (entry.summary.decisions.length > 0) {
26
+ content += `**Decisions made:**\n${entry.summary.decisions.map((d) => `- ${d}`).join('\n')}\n\n`;
27
+ }
28
+ if (entry.summary.drift.length > 0) {
29
+ content += `**Drift from plan:**\n${entry.summary.drift.map((d) => `- ${d}`).join('\n')}\n\n`;
30
+ }
31
+ if (entry.summary.forNextSession.length > 0) {
32
+ content += `**For next session:**\n${entry.summary.forNextSession.map((d) => `- ${d}`).join('\n')}\n\n`;
33
+ }
34
+ return content;
35
+ }
36
+ export function updateContext(entry) {
37
+ const path = getContextPath();
38
+ let existingContent = '';
39
+ let previousSessions = [];
40
+ if (existsSync(path)) {
41
+ existingContent = readFileSync(path, 'utf-8');
42
+ // Extract previous sessions
43
+ const sessionMatches = existingContent.match(/## Session:[\s\S]*?(?=## Session:|## Previous Sessions|$)/g);
44
+ if (sessionMatches) {
45
+ previousSessions = sessionMatches.slice(0, MAX_PREVIOUS_SESSIONS - 1);
46
+ }
47
+ }
48
+ // Build new context file
49
+ let newContent = `# Project Context
50
+
51
+ > This file is auto-generated by drift. It helps AI assistants understand recent changes.
52
+ > Last updated: ${new Date().toISOString()}
53
+
54
+ # Latest Session
55
+
56
+ ${formatSessionEntry(entry)}`;
57
+ if (previousSessions.length > 0) {
58
+ newContent += `# Previous Sessions
59
+
60
+ ${previousSessions.join('\n---\n\n')}`;
61
+ }
62
+ writeFileSync(path, newContent);
63
+ }
64
+ export function getLatestSessionContext() {
65
+ const content = readContext();
66
+ if (!content)
67
+ return null;
68
+ // Extract the latest session section
69
+ const match = content.match(/# Latest Session\s*([\s\S]*?)(?=# Previous Sessions|$)/);
70
+ if (match) {
71
+ return match[1].trim();
72
+ }
73
+ return null;
74
+ }
@@ -0,0 +1,16 @@
1
+ export declare function isGitRepo(): Promise<boolean>;
2
+ export declare function getCurrentSha(): Promise<string>;
3
+ export declare function getCurrentBranch(): Promise<string>;
4
+ export declare function getDiffSince(sha: string): Promise<string>;
5
+ export declare function getDiffStatSince(sha: string): Promise<string>;
6
+ export declare function getCommitsSince(sha: string): Promise<string[]>;
7
+ export declare function getChangedFiles(sha: string): Promise<string[]>;
8
+ export declare function getRepoRoot(): Promise<string>;
9
+ export interface GitStatus {
10
+ currentSha: string;
11
+ branch: string;
12
+ changedFiles: string[];
13
+ diffStat: string;
14
+ commits: string[];
15
+ }
16
+ export declare function getStatusSince(startSha: string): Promise<GitStatus>;
@@ -0,0 +1,82 @@
1
+ import { simpleGit } from 'simple-git';
2
+ const git = simpleGit();
3
+ export async function isGitRepo() {
4
+ try {
5
+ await git.revparse(['--git-dir']);
6
+ return true;
7
+ }
8
+ catch {
9
+ return false;
10
+ }
11
+ }
12
+ export async function getCurrentSha() {
13
+ const result = await git.revparse(['HEAD']);
14
+ return result.trim();
15
+ }
16
+ export async function getCurrentBranch() {
17
+ const result = await git.revparse(['--abbrev-ref', 'HEAD']);
18
+ return result.trim();
19
+ }
20
+ export async function getDiffSince(sha) {
21
+ try {
22
+ // Compare start SHA to working directory (includes uncommitted changes)
23
+ const diff = await git.diff([sha]);
24
+ return diff;
25
+ }
26
+ catch {
27
+ // Fallback: get all uncommitted changes
28
+ const diff = await git.diff();
29
+ return diff;
30
+ }
31
+ }
32
+ export async function getDiffStatSince(sha) {
33
+ try {
34
+ // Compare start SHA to working directory (includes uncommitted changes)
35
+ const stat = await git.diff([sha, '--stat']);
36
+ return stat;
37
+ }
38
+ catch {
39
+ const stat = await git.diff(['--stat']);
40
+ return stat;
41
+ }
42
+ }
43
+ export async function getCommitsSince(sha) {
44
+ try {
45
+ const log = await git.log({ from: sha, to: 'HEAD' });
46
+ return log.all.map((commit) => `${commit.hash.slice(0, 7)} ${commit.message}`);
47
+ }
48
+ catch {
49
+ return [];
50
+ }
51
+ }
52
+ export async function getChangedFiles(sha) {
53
+ try {
54
+ // Compare start SHA to working directory (includes uncommitted changes)
55
+ const diff = await git.diff([sha, '--name-only']);
56
+ return diff.split('\n').filter(Boolean);
57
+ }
58
+ catch {
59
+ const diff = await git.diff(['--name-only']);
60
+ return diff.split('\n').filter(Boolean);
61
+ }
62
+ }
63
+ export async function getRepoRoot() {
64
+ const root = await git.revparse(['--show-toplevel']);
65
+ return root.trim();
66
+ }
67
+ export async function getStatusSince(startSha) {
68
+ const [currentSha, branch, changedFiles, diffStat, commits] = await Promise.all([
69
+ getCurrentSha(),
70
+ getCurrentBranch(),
71
+ getChangedFiles(startSha),
72
+ getDiffStatSince(startSha),
73
+ getCommitsSince(startSha),
74
+ ]);
75
+ return {
76
+ currentSha,
77
+ branch,
78
+ changedFiles,
79
+ diffStat,
80
+ commits,
81
+ };
82
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "drift-ai",
3
+ "version": "0.1.0",
4
+ "description": "Track how your code drifts from the plan during AI coding sessions",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "drift": "./dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "dev": "tsc -w",
17
+ "start": "node dist/index.js",
18
+ "lint": "eslint src/",
19
+ "prepublishOnly": "npm run build",
20
+ "drift": "node dist/index.js"
21
+ },
22
+ "keywords": [
23
+ "ai",
24
+ "coding",
25
+ "context",
26
+ "jira",
27
+ "github",
28
+ "cli",
29
+ "cursor",
30
+ "copilot",
31
+ "vibe-coding"
32
+ ],
33
+ "author": "mersall",
34
+ "license": "MIT",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "https://github.com/mersall/drift-ai"
38
+ },
39
+ "dependencies": {
40
+ "@anthropic-ai/sdk": "^0.39.0",
41
+ "chalk": "^5.3.0",
42
+ "commander": "^12.1.0",
43
+ "conf": "^13.0.1",
44
+ "dotenv": "^16.4.7",
45
+ "groq-sdk": "^0.37.0",
46
+ "ora": "^8.1.1",
47
+ "simple-git": "^3.27.0"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^22.10.0",
51
+ "typescript": "^5.7.2"
52
+ }
53
+ }