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 +145 -0
- package/dist/commands/config.d.ts +9 -0
- package/dist/commands/config.js +54 -0
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +56 -0
- package/dist/commands/log.d.ts +5 -0
- package/dist/commands/log.js +44 -0
- package/dist/commands/start.d.ts +5 -0
- package/dist/commands/start.js +73 -0
- package/dist/commands/status.d.ts +1 -0
- package/dist/commands/status.js +70 -0
- package/dist/commands/sync.d.ts +6 -0
- package/dist/commands/sync.js +122 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +47 -0
- package/dist/integrations/jira.d.ts +10 -0
- package/dist/integrations/jira.js +74 -0
- package/dist/lib/ai.d.ts +8 -0
- package/dist/lib/ai.js +73 -0
- package/dist/lib/config.d.ts +25 -0
- package/dist/lib/config.js +73 -0
- package/dist/lib/context.d.ts +11 -0
- package/dist/lib/context.js +74 -0
- package/dist/lib/git.d.ts +16 -0
- package/dist/lib/git.js +82 -0
- package/package.json +53 -0
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,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,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,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,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
|
+
}
|
package/dist/index.d.ts
ADDED
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
|
+
}
|
package/dist/lib/ai.d.ts
ADDED
|
@@ -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>;
|
package/dist/lib/git.js
ADDED
|
@@ -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
|
+
}
|