@qelos/aidev 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.
Files changed (60) hide show
  1. package/.claude/settings.local.json +9 -0
  2. package/.cursor/rules/aidev.mdc +57 -0
  3. package/.env.aidev.example +17 -0
  4. package/CLAUDE.md +32 -0
  5. package/CONTRIBUTING.md +78 -0
  6. package/LICENSE +21 -0
  7. package/README.md +245 -0
  8. package/dist/ai/base.d.ts.map +1 -0
  9. package/dist/ai/base.js +3 -0
  10. package/dist/ai/claude.d.ts.map +1 -0
  11. package/dist/ai/claude.js +33 -0
  12. package/dist/ai/cursor.d.ts.map +1 -0
  13. package/dist/ai/cursor.js +33 -0
  14. package/dist/ai/index.d.ts.map +1 -0
  15. package/dist/ai/index.js +13 -0
  16. package/dist/cli.d.ts.map +1 -0
  17. package/dist/cli.js +74 -0
  18. package/dist/commands/help.d.ts.map +1 -0
  19. package/dist/commands/help.js +48 -0
  20. package/dist/commands/init.d.ts.map +1 -0
  21. package/dist/commands/init.js +255 -0
  22. package/dist/commands/run.d.ts.map +1 -0
  23. package/dist/commands/run.js +257 -0
  24. package/dist/commands/schedule.d.ts.map +1 -0
  25. package/dist/commands/schedule.js +191 -0
  26. package/dist/config.d.ts.map +1 -0
  27. package/dist/config.js +89 -0
  28. package/dist/git.d.ts.map +1 -0
  29. package/dist/git.js +108 -0
  30. package/dist/logger.d.ts.map +1 -0
  31. package/dist/logger.js +88 -0
  32. package/dist/platform.d.ts.map +1 -0
  33. package/dist/platform.js +69 -0
  34. package/dist/providers/base.d.ts.map +1 -0
  35. package/dist/providers/base.js +3 -0
  36. package/dist/providers/clickup.d.ts.map +1 -0
  37. package/dist/providers/clickup.js +67 -0
  38. package/dist/providers/index.d.ts.map +1 -0
  39. package/dist/providers/index.js +19 -0
  40. package/dist/types.d.ts.map +1 -0
  41. package/dist/types.js +3 -0
  42. package/package.json +28 -0
  43. package/src/ai/base.ts +11 -0
  44. package/src/ai/claude.ts +35 -0
  45. package/src/ai/cursor.ts +35 -0
  46. package/src/ai/index.ts +15 -0
  47. package/src/cli.ts +83 -0
  48. package/src/commands/help.ts +43 -0
  49. package/src/commands/init.ts +296 -0
  50. package/src/commands/run.ts +283 -0
  51. package/src/commands/schedule.ts +179 -0
  52. package/src/config.ts +59 -0
  53. package/src/git.ts +109 -0
  54. package/src/logger.ts +53 -0
  55. package/src/platform.ts +33 -0
  56. package/src/providers/base.ts +8 -0
  57. package/src/providers/clickup.ts +107 -0
  58. package/src/providers/index.ts +20 -0
  59. package/src/types.ts +33 -0
  60. package/tsconfig.json +19 -0
@@ -0,0 +1,283 @@
1
+ import { Config, Task, Comment } from '../types';
2
+ import { TaskProvider } from '../providers';
3
+ import { AIRunner } from '../ai';
4
+ import { logger, logRunStart } from '../logger';
5
+ import * as git from '../git';
6
+
7
+ const SKIP_STATUSES = new Set(['closed', 'done', 'cancelled', 'complete']);
8
+
9
+ export type RunFilter = 'all' | 'open' | 'pending';
10
+
11
+ export async function runCommand(
12
+ filter: RunFilter,
13
+ config: Config,
14
+ provider: TaskProvider,
15
+ runners: AIRunner[]
16
+ ): Promise<void> {
17
+ logRunStart();
18
+ logger.info(`Fetching tasks (filter: ${filter})...`);
19
+ const tasks = await provider.fetchTasks();
20
+ logger.info(`Found ${tasks.length} tagged task(s)`);
21
+
22
+ let processed = 0;
23
+ let skipped = 0;
24
+
25
+ for (const task of tasks) {
26
+ const result = await processTask(task, filter, config, provider, runners);
27
+ if (result === 'processed') processed++;
28
+ else skipped++;
29
+ }
30
+
31
+ logger.success(`Done. Processed: ${processed}, Skipped: ${skipped}`);
32
+ }
33
+
34
+ async function processTask(
35
+ task: Task,
36
+ filter: RunFilter,
37
+ config: Config,
38
+ provider: TaskProvider,
39
+ runners: AIRunner[]
40
+ ): Promise<'processed' | 'skipped'> {
41
+ const isPending = task.status.toLowerCase() === config.clickupPendingStatus.toLowerCase();
42
+
43
+ logger.task(`[${task.id}] "${task.name}" (status: ${task.status})`);
44
+
45
+ // Skip terminal statuses
46
+ if (SKIP_STATUSES.has(task.status.toLowerCase())) {
47
+ logger.debug(`Skipping — terminal status: ${task.status}`);
48
+ return 'skipped';
49
+ }
50
+
51
+ // Skip if remote branch already exists for this task
52
+ const branchName = `${task.id}/${git.slugify(task.name)}`;
53
+ if (git.remoteBranchExists(config.gitRemote, branchName)) {
54
+ logger.debug(`Skipping — branch already exists: ${branchName}`);
55
+ return 'skipped';
56
+ }
57
+
58
+ // Apply filter
59
+ if (filter === 'open' && isPending) {
60
+ logger.debug('Skipping — filter=open, task is pending');
61
+ return 'skipped';
62
+ }
63
+ if (filter === 'pending' && !isPending) {
64
+ logger.debug('Skipping — filter=pending, task is not pending');
65
+ return 'skipped';
66
+ }
67
+
68
+ // Handle pending tasks: check if a human replied
69
+ if (isPending) {
70
+ const hasReply = await checkForHumanReply(task, provider);
71
+ if (!hasReply) {
72
+ logger.debug('Skipping — pending task has no human reply yet');
73
+ return 'skipped';
74
+ }
75
+ logger.info('Pending task has a human reply — proceeding');
76
+ } else {
77
+ // Check if task needs clarification
78
+ const clarification = await checkNeedsClarification(task, config, provider, runners);
79
+ if (clarification) {
80
+ await provider.postComment(task.id, clarification);
81
+ await provider.updateStatus(task.id, config.clickupPendingStatus);
82
+ logger.info(`Posted clarification question, set status to ${config.clickupPendingStatus}`);
83
+ return 'skipped';
84
+ }
85
+ }
86
+
87
+ // Implement the task
88
+ await implementTask(task, branchName, config, provider, runners);
89
+ return 'processed';
90
+ }
91
+
92
+ async function checkForHumanReply(task: Task, provider: TaskProvider): Promise<boolean> {
93
+ const comments = await provider.getComments(task.id);
94
+ if (comments.length < 2) return false;
95
+
96
+ // The last comment should be from a human (not a bot — heuristic: not containing "[aidev]")
97
+ const lastComment = comments[comments.length - 1];
98
+ return !lastComment.text.includes('[aidev]');
99
+ }
100
+
101
+ async function checkNeedsClarification(
102
+ task: Task,
103
+ config: Config,
104
+ provider: TaskProvider,
105
+ runners: AIRunner[]
106
+ ): Promise<string | null> {
107
+ if (config.devNotesMode === 'always') {
108
+ return `Any dev notes or implementation preferences for this task?\n\nTask: ${task.name}`;
109
+ }
110
+
111
+ // smart mode: ask AI if the task is clear
112
+ const runner = runners.find((r) => r.isAvailable());
113
+ if (!runner) {
114
+ logger.warn('No AI runner available — skipping clarification check');
115
+ return null;
116
+ }
117
+
118
+ const clarificationPrompt = `You are a senior software developer reviewing a task.
119
+ Determine if the following task has enough information to implement without further clarification.
120
+
121
+ Task name: ${task.name}
122
+ Task description: ${task.description || '(no description)'}
123
+
124
+ Respond with valid JSON only:
125
+ {
126
+ "clear": true|false,
127
+ "question": "question to ask if not clear, or null"
128
+ }`;
129
+
130
+ const result = await runner.run(clarificationPrompt);
131
+ if (!result.success) {
132
+ logger.warn('Clarification check failed — proceeding without clarification');
133
+ return null;
134
+ }
135
+
136
+ try {
137
+ const jsonMatch = result.output.match(/\{[\s\S]*\}/);
138
+ if (!jsonMatch) return null;
139
+ const parsed = JSON.parse(jsonMatch[0]) as { clear: boolean; question?: string | null };
140
+ if (!parsed.clear && parsed.question) {
141
+ return parsed.question;
142
+ }
143
+ } catch {
144
+ logger.debug('Could not parse clarification response — proceeding');
145
+ }
146
+
147
+ return null;
148
+ }
149
+
150
+ async function implementTask(
151
+ task: Task,
152
+ branchName: string,
153
+ config: Config,
154
+ provider: TaskProvider,
155
+ runners: AIRunner[]
156
+ ): Promise<void> {
157
+ logger.info(`Implementing task: ${task.name}`);
158
+
159
+ // Mark as in progress
160
+ try {
161
+ await provider.updateStatus(task.id, 'in progress');
162
+ await provider.postComment(task.id, `[aidev] Starting implementation on branch \`${branchName}\``);
163
+ } catch (err) {
164
+ logger.warn(`Could not update task status: ${err}`);
165
+ }
166
+
167
+ // Prepare git branch
168
+ if (!git.fetchAndCheckout(config.gitRemote, config.githubBaseBranch)) {
169
+ logger.error('Failed to prepare base branch');
170
+ await provider.postComment(task.id, '[aidev] Failed to prepare git branch. Manual intervention needed.');
171
+ return;
172
+ }
173
+
174
+ if (!git.createBranch(branchName)) {
175
+ logger.error(`Failed to create branch ${branchName}`);
176
+ return;
177
+ }
178
+
179
+ // Get conversation context for pending tasks
180
+ let context = '';
181
+ try {
182
+ const comments = await provider.getComments(task.id);
183
+ if (comments.length > 0) {
184
+ context = '\n\nConversation context:\n' + comments.map((c) => `${c.author}: ${c.text}`).join('\n');
185
+ }
186
+ } catch {
187
+ // ignore
188
+ }
189
+
190
+ const implementPrompt = buildImplementPrompt(task, context);
191
+
192
+ // Run AI runners in order with fallback
193
+ let implemented = false;
194
+ let previousNotes = '';
195
+
196
+ for (const runner of runners) {
197
+ if (!runner.isAvailable()) {
198
+ logger.debug(`${runner.name} not available, skipping`);
199
+ continue;
200
+ }
201
+
202
+ logger.info(`Running ${runner.name}...`);
203
+ const result = await runner.run(implementPrompt, previousNotes || undefined);
204
+
205
+ if (result.success) {
206
+ implemented = true;
207
+ break;
208
+ }
209
+
210
+ logger.warn(`${runner.name} failed — trying next runner`);
211
+ previousNotes = `Previous runner (${runner.name}) output:\n${result.output}\nErrors:\n${result.error}`;
212
+ }
213
+
214
+ if (!implemented) {
215
+ logger.error('All AI runners failed');
216
+ await provider.postComment(task.id, '[aidev] All AI runners failed. Manual implementation needed.');
217
+ git.deleteBranch(branchName);
218
+ return;
219
+ }
220
+
221
+ // Check if AI made changes
222
+ if (!git.hasChanges()) {
223
+ logger.warn('AI runner produced no file changes');
224
+ await provider.postComment(
225
+ task.id,
226
+ '[aidev] AI runner completed but made no code changes. More information may be needed.'
227
+ );
228
+ git.deleteBranch(branchName);
229
+ return;
230
+ }
231
+
232
+ // Commit and push
233
+ if (!git.addAll() || !git.commit(`[aidev] Implement: ${task.name}\n\nTask: ${task.url}`)) {
234
+ logger.error('Failed to commit changes');
235
+ return;
236
+ }
237
+
238
+ if (!git.push(config.gitRemote, branchName)) {
239
+ logger.error('Failed to push branch');
240
+ return;
241
+ }
242
+
243
+ // Post completion comment
244
+ const prUrl = buildPRUrl(config, branchName);
245
+ const comment = buildCompletionComment(branchName, prUrl, config);
246
+ await provider.postComment(task.id, comment);
247
+ await provider.updateStatus(task.id, config.clickupInReviewStatus);
248
+
249
+ logger.success(`Task implemented: branch ${branchName} pushed`);
250
+ }
251
+
252
+ function buildImplementPrompt(task: Task, context: string): string {
253
+ return `You are implementing a software development task. Make the necessary code changes to complete the task described below.
254
+
255
+ Task: ${task.name}
256
+
257
+ Description:
258
+ ${task.description || '(no description provided)'}
259
+ ${context}
260
+
261
+ Please implement the required changes. Focus on correctness and follow the existing code style in the project.`;
262
+ }
263
+
264
+ function buildPRUrl(config: Config, branch: string): string {
265
+ if (!config.githubRepo) return '';
266
+ const encoded = encodeURIComponent(branch);
267
+ return `https://github.com/${config.githubRepo}/compare/${config.githubBaseBranch}...${encoded}?expand=1`;
268
+ }
269
+
270
+ function buildCompletionComment(branch: string, prUrl: string, config: Config): string {
271
+ const lines = [
272
+ `[aidev] Implementation complete!`,
273
+ ``,
274
+ `Branch: \`${branch}\``,
275
+ ];
276
+
277
+ if (prUrl) {
278
+ lines.push(`Open PR: ${prUrl}`);
279
+ }
280
+
281
+ lines.push(``, `Status set to: ${config.clickupInReviewStatus}`);
282
+ return lines.join('\n');
283
+ }
@@ -0,0 +1,179 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import * as readline from 'node:readline/promises';
3
+ import { stdin as input, stdout as output } from 'node:process';
4
+ import { logger } from '../logger';
5
+ import { isWindows, findBin } from '../platform';
6
+ import chalk from 'chalk';
7
+
8
+ // ─── Preset schedules ────────────────────────────────────────────────────────
9
+
10
+ const PRESETS: Array<{ label: string; cron: string }> = [
11
+ { label: 'Every 15 minutes', cron: '*/15 * * * *' },
12
+ { label: 'Every 30 minutes', cron: '*/30 * * * *' },
13
+ { label: 'Every hour', cron: '0 * * * *' },
14
+ { label: 'Every 5 hours', cron: '0 */5 * * *' },
15
+ { label: 'Every day at 8am', cron: '0 8 * * *' },
16
+ ];
17
+
18
+ async function pickCron(): Promise<string> {
19
+ console.log('\n Select a schedule:');
20
+ PRESETS.forEach((p, i) =>
21
+ console.log(` ${chalk.cyan(String(i + 1))}. ${p.label} ${chalk.dim(p.cron)}`)
22
+ );
23
+
24
+ const rl = readline.createInterface({ input, output });
25
+ try {
26
+ while (true) {
27
+ const raw = await rl.question(`\n Choice ${chalk.dim('[1]')}: `);
28
+ const val = raw.trim() || '1';
29
+ const idx = parseInt(val, 10);
30
+ if (idx >= 1 && idx <= PRESETS.length) return PRESETS[idx - 1].cron;
31
+ console.log(chalk.yellow(` Enter a number between 1 and ${PRESETS.length}.`));
32
+ }
33
+ } finally {
34
+ rl.close();
35
+ }
36
+ }
37
+
38
+ // ─── Shared helpers ───────────────────────────────────────────────────────────
39
+
40
+ function getAidevBin(): string {
41
+ return findBin('aidev') ?? 'aidev';
42
+ }
43
+
44
+ // ─── Unix (crontab) ───────────────────────────────────────────────────────────
45
+
46
+ const UNIX_MARKER_PREFIX = '# aidev-cwd:';
47
+
48
+ function getCrontab(): string {
49
+ const result = spawnSync('crontab', ['-l'], { encoding: 'utf8' });
50
+ return result.status === 0 ? result.stdout || '' : '';
51
+ }
52
+
53
+ function setCrontab(content: string): boolean {
54
+ const result = spawnSync('crontab', ['-'], { input: content, encoding: 'utf8' });
55
+ return result.status === 0;
56
+ }
57
+
58
+ function scheduleSetUnix(cronExpr: string): void {
59
+ const cwd = process.cwd();
60
+ const marker = `${UNIX_MARKER_PREFIX}${cwd}`;
61
+ const aidevBin = getAidevBin();
62
+ const newLine = `${cronExpr} cd ${cwd} && ${aidevBin} run ${marker}`;
63
+
64
+ const lines = getCrontab().split('\n').filter((l) => !l.includes(marker));
65
+ lines.push(newLine);
66
+ const updated = lines.join('\n').replace(/\n+$/, '') + '\n';
67
+
68
+ if (setCrontab(updated)) {
69
+ logger.success(`Cron schedule set: ${cronExpr}`);
70
+ logger.info(`Entry: ${newLine}`);
71
+ } else {
72
+ logger.error('Failed to update crontab');
73
+ process.exit(1);
74
+ }
75
+ }
76
+
77
+ function scheduleGetUnix(): void {
78
+ const cwd = process.cwd();
79
+ const marker = `${UNIX_MARKER_PREFIX}${cwd}`;
80
+ const entry = getCrontab().split('\n').find((l) => l.includes(marker));
81
+
82
+ if (entry) {
83
+ logger.info(`Current schedule for ${cwd}:`);
84
+ console.log(entry);
85
+ } else {
86
+ logger.warn(`No schedule found for ${cwd}`);
87
+ logger.info('Use "aidev schedule set" to configure one.');
88
+ }
89
+ }
90
+
91
+ // ─── Windows (schtasks) ───────────────────────────────────────────────────────
92
+
93
+ /**
94
+ * Converts a cron expression to schtasks /sc + /mo + /st arguments.
95
+ * Supports the subset used by the preset list.
96
+ */
97
+ function cronToSchtasksArgs(cron: string): string[] | null {
98
+ // */N * * * * → every N minutes
99
+ const everyMin = cron.match(/^\*\/(\d+) \* \* \* \*$/);
100
+ if (everyMin) return ['/sc', 'MINUTE', '/mo', everyMin[1]];
101
+
102
+ // 0 * * * * → every hour
103
+ if (cron === '0 * * * *') return ['/sc', 'HOURLY', '/mo', '1'];
104
+
105
+ // 0 */N * * * → every N hours
106
+ const everyHour = cron.match(/^0 \*\/(\d+) \* \* \*$/);
107
+ if (everyHour) return ['/sc', 'HOURLY', '/mo', everyHour[1]];
108
+
109
+ // 0 H * * * → daily at H:00
110
+ const daily = cron.match(/^0 (\d+) \* \* \*$/);
111
+ if (daily) return ['/sc', 'DAILY', '/st', `${daily[1].padStart(2, '0')}:00`];
112
+
113
+ return null;
114
+ }
115
+
116
+ /** Stable task name derived from cwd — safe for Task Scheduler. */
117
+ function windowsTaskName(cwd: string): string {
118
+ const sanitized = cwd.replace(/[:\\\/]+/g, '-').replace(/^-+|-+$/g, '');
119
+ return `aidev\\${sanitized}`;
120
+ }
121
+
122
+ function scheduleSetWindows(cronExpr: string): void {
123
+ const cwd = process.cwd();
124
+ const schtasksArgs = cronToSchtasksArgs(cronExpr);
125
+ if (!schtasksArgs) {
126
+ logger.error(
127
+ `Cron expression "${cronExpr}" cannot be mapped to Windows Task Scheduler.\n` +
128
+ ' Use "aidev schedule set" (no argument) to choose a supported preset.'
129
+ );
130
+ process.exit(1);
131
+ }
132
+
133
+ const taskName = windowsTaskName(cwd);
134
+ const aidevBin = getAidevBin();
135
+ // cmd /c: run command and exit; /d: change drive+dir
136
+ const command = `cmd /c cd /d "${cwd}" && "${aidevBin}" run`;
137
+
138
+ const result = spawnSync(
139
+ 'schtasks',
140
+ ['/create', '/f', '/tn', taskName, '/tr', command, ...schtasksArgs],
141
+ { encoding: 'utf8' }
142
+ );
143
+
144
+ if (result.status === 0) {
145
+ logger.success(`Task Scheduler entry created: ${taskName}`);
146
+ logger.info(`Schedule: ${cronExpr}`);
147
+ } else {
148
+ logger.error(`Failed to create Task Scheduler entry:\n${result.stderr}`);
149
+ process.exit(1);
150
+ }
151
+ }
152
+
153
+ function scheduleGetWindows(): void {
154
+ const cwd = process.cwd();
155
+ const taskName = windowsTaskName(cwd);
156
+
157
+ const result = spawnSync('schtasks', ['/query', '/tn', taskName, '/fo', 'LIST'], {
158
+ encoding: 'utf8',
159
+ });
160
+
161
+ if (result.status === 0) {
162
+ logger.info(`Task Scheduler entry for ${cwd}:`);
163
+ console.log(result.stdout.trim());
164
+ } else {
165
+ logger.warn(`No Task Scheduler entry found for ${cwd}`);
166
+ logger.info('Use "aidev schedule set" to configure one.');
167
+ }
168
+ }
169
+
170
+ // ─── Public API ───────────────────────────────────────────────────────────────
171
+
172
+ export async function scheduleSetCommand(cronExpr?: string): Promise<void> {
173
+ if (!cronExpr) cronExpr = await pickCron();
174
+ isWindows ? scheduleSetWindows(cronExpr) : scheduleSetUnix(cronExpr);
175
+ }
176
+
177
+ export async function scheduleGetCommand(): Promise<void> {
178
+ isWindows ? scheduleGetWindows() : scheduleGetUnix();
179
+ }
package/src/config.ts ADDED
@@ -0,0 +1,59 @@
1
+ import * as dotenv from 'dotenv';
2
+ import * as path from 'node:path';
3
+ import * as fs from 'node:fs';
4
+ import { Config, AgentName } from './types';
5
+ import { detectRemote } from './git';
6
+
7
+ export function loadConfig(customEnvPath?: string): Config {
8
+ const envPath = customEnvPath
9
+ ? path.resolve(customEnvPath)
10
+ : path.join(process.cwd(), '.env.aidev');
11
+
12
+ if (customEnvPath && !fs.existsSync(envPath)) {
13
+ throw new Error(`Env file not found: ${envPath}`);
14
+ }
15
+ if (fs.existsSync(envPath)) {
16
+ dotenv.config({ path: envPath });
17
+ }
18
+
19
+ const required = ['CLICKUP_API_KEY', 'CLICKUP_TEAM_ID', 'CLICKUP_TAG'];
20
+ for (const key of required) {
21
+ if (!process.env[key]) {
22
+ throw new Error(`Missing required config: ${key}. Run 'aidev init' to create .env.aidev`);
23
+ }
24
+ }
25
+
26
+ const validAgents: AgentName[] = ['claude', 'cursor'];
27
+ const agentsRaw = process.env.AGENTS || 'claude,cursor';
28
+ const agents = agentsRaw
29
+ .split(',')
30
+ .map((s) => s.trim().toLowerCase())
31
+ .filter(Boolean) as AgentName[];
32
+ const invalid = agents.filter((a) => !validAgents.includes(a));
33
+ if (invalid.length) {
34
+ throw new Error(`Invalid agent(s): ${invalid.join(', ')}. Valid: ${validAgents.join(', ')}`);
35
+ }
36
+ if (agents.length === 0) {
37
+ throw new Error(`AGENTS must contain at least one agent. Valid: ${validAgents.join(', ')}`);
38
+ }
39
+
40
+ const devNotesMode = (process.env.DEV_NOTES_MODE || 'smart') as Config['devNotesMode'];
41
+ if (!['smart', 'always'].includes(devNotesMode)) {
42
+ throw new Error(`Invalid DEV_NOTES_MODE: ${devNotesMode}. Must be smart or always`);
43
+ }
44
+
45
+ return {
46
+ provider: process.env.PROVIDER || 'clickup',
47
+ clickupApiKey: process.env.CLICKUP_API_KEY!,
48
+ clickupTeamId: process.env.CLICKUP_TEAM_ID!,
49
+ clickupTag: process.env.CLICKUP_TAG!,
50
+ clickupPendingStatus: process.env.CLICKUP_PENDING_STATUS || 'pending',
51
+ clickupInReviewStatus: process.env.CLICKUP_IN_REVIEW_STATUS || 'review',
52
+ assigneeTag: process.env.ASSIGNEE_TAG || '',
53
+ gitRemote: process.env.GIT_REMOTE || detectRemote() || 'origin',
54
+ githubBaseBranch: process.env.GITHUB_BASE_BRANCH || 'main',
55
+ githubRepo: process.env.GITHUB_REPO || '',
56
+ agents,
57
+ devNotesMode,
58
+ };
59
+ }
package/src/git.ts ADDED
@@ -0,0 +1,109 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { logger } from './logger';
3
+
4
+ function git(args: string[], cwd?: string): { stdout: string; stderr: string; status: number } {
5
+ const result = spawnSync('git', args, {
6
+ cwd: cwd || process.cwd(),
7
+ encoding: 'utf8',
8
+ });
9
+ return {
10
+ stdout: result.stdout || '',
11
+ stderr: result.stderr || '',
12
+ status: result.status ?? 1,
13
+ };
14
+ }
15
+
16
+ export function remoteBranchExists(remote: string, branch: string): boolean {
17
+ const result = git(['ls-remote', '--heads', remote, branch]);
18
+ return result.status === 0 && result.stdout.trim().length > 0;
19
+ }
20
+
21
+ export function fetchAndCheckout(remote: string, baseBranch: string): boolean {
22
+ logger.debug(`git fetch ${remote}`);
23
+ const fetch = git(['fetch', remote]);
24
+ if (fetch.status !== 0) {
25
+ logger.error(`git fetch failed: ${fetch.stderr}`);
26
+ return false;
27
+ }
28
+
29
+ logger.debug(`git checkout ${baseBranch}`);
30
+ const checkout = git(['checkout', baseBranch]);
31
+ if (checkout.status !== 0) {
32
+ logger.error(`git checkout ${baseBranch} failed: ${checkout.stderr}`);
33
+ return false;
34
+ }
35
+
36
+ logger.debug(`git pull ${remote} ${baseBranch}`);
37
+ const pull = git(['pull', remote, baseBranch]);
38
+ if (pull.status !== 0) {
39
+ logger.error(`git pull failed: ${pull.stderr}`);
40
+ return false;
41
+ }
42
+
43
+ return true;
44
+ }
45
+
46
+ export function createBranch(branch: string): boolean {
47
+ logger.debug(`git checkout -b ${branch}`);
48
+ const result = git(['checkout', '-b', branch]);
49
+ if (result.status !== 0) {
50
+ logger.error(`git checkout -b ${branch} failed: ${result.stderr}`);
51
+ return false;
52
+ }
53
+ return true;
54
+ }
55
+
56
+ export function hasChanges(): boolean {
57
+ const result = git(['status', '--porcelain']);
58
+ return result.status === 0 && result.stdout.trim().length > 0;
59
+ }
60
+
61
+ export function addAll(): boolean {
62
+ const result = git(['add', '-A']);
63
+ return result.status === 0;
64
+ }
65
+
66
+ export function commit(message: string): boolean {
67
+ logger.debug(`git commit -m "${message}"`);
68
+ const result = git(['commit', '-m', message]);
69
+ if (result.status !== 0) {
70
+ logger.error(`git commit failed: ${result.stderr}`);
71
+ return false;
72
+ }
73
+ return true;
74
+ }
75
+
76
+ export function push(remote: string, branch: string): boolean {
77
+ logger.debug(`git push ${remote} ${branch}`);
78
+ const result = git(['push', remote, branch]);
79
+ if (result.status !== 0) {
80
+ logger.error(`git push failed: ${result.stderr}`);
81
+ return false;
82
+ }
83
+ return true;
84
+ }
85
+
86
+ export function deleteBranch(branch: string): void {
87
+ git(['checkout', '-']);
88
+ git(['branch', '-D', branch]);
89
+ }
90
+
91
+ /** Returns the name of the first usable remote (prefers origin). */
92
+ export function detectRemote(): string | null {
93
+ // Verify origin exists first
94
+ const originCheck = git(['remote', 'get-url', 'origin']);
95
+ if (originCheck.status === 0) return 'origin';
96
+
97
+ // Fall back to first listed remote
98
+ const list = git(['remote']);
99
+ const first = list.stdout.trim().split('\n')[0]?.trim();
100
+ return first || null;
101
+ }
102
+
103
+ export function slugify(text: string): string {
104
+ return text
105
+ .toLowerCase()
106
+ .replace(/[^a-z0-9]+/g, '-')
107
+ .replace(/^-|-$/g, '')
108
+ .slice(0, 50);
109
+ }
package/src/logger.ts ADDED
@@ -0,0 +1,53 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import chalk from 'chalk';
4
+
5
+ const LOG_FILE = path.join(process.cwd(), 'aidev.log');
6
+
7
+ // Strip ANSI escape codes for clean file output
8
+ function strip(s: string): string {
9
+ return s.replace(/\x1B\[[0-9;]*m/g, '');
10
+ }
11
+
12
+ function timestamp(): string {
13
+ return new Date().toISOString();
14
+ }
15
+
16
+ function writeLog(level: string, msg: string): void {
17
+ const line = `${timestamp()} [${level}] ${strip(msg)}\n`;
18
+ fs.appendFileSync(LOG_FILE, line, 'utf8');
19
+ }
20
+
21
+ export function logRunStart(): void {
22
+ const sep = '─'.repeat(60);
23
+ fs.appendFileSync(LOG_FILE, `\n${sep}\n${timestamp()} [run] started\n${sep}\n`, 'utf8');
24
+ }
25
+
26
+ export const logger = {
27
+ info: (msg: string) => {
28
+ console.log(chalk.blue('[aidev]'), msg);
29
+ writeLog('info', msg);
30
+ },
31
+ success: (msg: string) => {
32
+ console.log(chalk.green('[aidev]'), msg);
33
+ writeLog('success', msg);
34
+ },
35
+ warn: (msg: string) => {
36
+ console.log(chalk.yellow('[aidev]'), msg);
37
+ writeLog('warn', msg);
38
+ },
39
+ error: (msg: string) => {
40
+ console.error(chalk.red('[aidev]'), msg);
41
+ writeLog('error', msg);
42
+ },
43
+ task: (msg: string) => {
44
+ console.log(chalk.cyan('[task]'), msg);
45
+ writeLog('task', msg);
46
+ },
47
+ debug: (msg: string) => {
48
+ if (process.env.DEBUG) {
49
+ console.log(chalk.gray('[debug]'), msg);
50
+ writeLog('debug', msg);
51
+ }
52
+ },
53
+ };