@relipa/ai-flow-kit 0.0.4-beta.2 → 0.0.4

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 (33) hide show
  1. package/custom/skills/investigate-bug/SKILL.md +1 -1
  2. package/custom/skills/validate-ticket/SKILL.md +1 -1
  3. package/custom/templates/laravel.md +15 -15
  4. package/custom/templates/nestjs.md +72 -72
  5. package/custom/templates/nextjs.md +14 -14
  6. package/custom/templates/nodejs-express.md +73 -73
  7. package/custom/templates/python-django.md +71 -71
  8. package/custom/templates/python-fastapi.md +54 -54
  9. package/custom/templates/reactjs.md +492 -492
  10. package/custom/templates/shared/gate-workflow.md +75 -75
  11. package/custom/templates/spring-boot.md +523 -523
  12. package/custom/templates/tools/claude.md +13 -0
  13. package/custom/templates/tools/copilot.md +12 -8
  14. package/custom/templates/tools/cursor.md +12 -8
  15. package/custom/templates/tools/gemini.md +12 -8
  16. package/custom/templates/tools/generic.md +17 -12
  17. package/custom/templates/vue-nuxt.md +14 -14
  18. package/{AIFLOW.md → docs/AIFLOW.md} +2 -2
  19. package/{CHANGELOG.md → docs/CHANGELOG.md} +14 -3
  20. package/{IMPLEMENTATION_SUMMARY.md → docs/IMPLEMENTATION_SUMMARY.md} +21 -39
  21. package/{QUICK_START.md → docs/QUICK_START.md} +10 -10
  22. package/{README.md → docs/README.md} +12 -7
  23. package/docs/ai-integration.md +53 -53
  24. package/docs/architecture.md +5 -6
  25. package/docs/cli-reference.md +2 -2
  26. package/docs/developer-overview.md +126 -126
  27. package/docs/troubleshooting.md +1 -1
  28. package/package.json +2 -8
  29. package/scripts/hooks/session-start.js +2 -2
  30. package/scripts/init.js +501 -460
  31. package/scripts/prompt.js +1 -1
  32. package/scripts/use.js +594 -594
  33. package/CONTRIBUTING.md +0 -388
package/scripts/use.js CHANGED
@@ -1,594 +1,594 @@
1
- const fs = require('fs-extra');
2
- const path = require('path');
3
- const chalk = require('chalk');
4
- const https = require('https');
5
- const { select, input } = require('@inquirer/prompts');
6
-
7
- const PROJECT_DIR = process.cwd();
8
- const AIFLOW_DIR = path.join(PROJECT_DIR, '.aiflow');
9
- const STATE_FILE = path.join(AIFLOW_DIR, 'state.json');
10
- const CONTEXT_DIR = path.join(PROJECT_DIR, '.claude', 'context');
11
-
12
- // ──────────────────────────────────────────────────────────────
13
- // Entry point
14
- // ──────────────────────────────────────────────────────────────
15
-
16
- module.exports = async function use(target, options = {}) {
17
- if (!(await fs.pathExists(STATE_FILE))) {
18
- console.log(chalk.red('Project is not initialized. Run `aiflow init` first.'));
19
- return;
20
- }
21
-
22
- // ── Manual entry ──
23
- if (options.manual) {
24
- return await manualContext();
25
- }
26
-
27
- // ── Load from local file ──
28
- if (options.file) {
29
- return await loadFromFile(options.file, options);
30
- }
31
-
32
- // ── No target → version switcher ──
33
- if (!target) {
34
- return await switchVersion();
35
- }
36
-
37
- // ── Detect target type and dispatch ──
38
- if (isVersion(target)) {
39
- return await switchVersion(target);
40
- }
41
-
42
- if (isTicketId(target)) {
43
- const adapter = await resolveTicketAdapter();
44
- if (adapter === 'jira') return await loadFromJira(target, options);
45
- return await loadFromBacklog(target, options);
46
- }
47
-
48
- if (isBacklogUrl(target)) {
49
- const id = extractBacklogId(target);
50
- return await loadFromBacklog(id, options);
51
- }
52
-
53
- // ── Fallback: try Backlog then manual ──
54
- console.log(chalk.yellow(`Cannot detect format for: ${target}`));
55
- console.log(chalk.gray('Supported: PROJ-33, APP-123, version number, --manual'));
56
- return await manualContext();
57
- };
58
-
59
- // ──────────────────────────────────────────────────────────────
60
- // Detectors
61
- // ──────────────────────────────────────────────────────────────
62
-
63
- function isVersion(s) {
64
- return /^\d+\.\d+\.\d+$/.test(s);
65
- }
66
-
67
- /** Both Backlog and Jira use the same PROJECT-NUMBER format. */
68
- function isTicketId(s) {
69
- return /^[A-Z][A-Z0-9_]+-\d+$/.test(s);
70
- }
71
-
72
- /**
73
- * Determine which adapter to use for a ticket ID based on configured adapters
74
- * in state.json. Falls back to backlog if both or neither are configured.
75
- */
76
- async function resolveTicketAdapter() {
77
- try {
78
- if (await fs.pathExists(STATE_FILE)) {
79
- const state = await fs.readJson(STATE_FILE);
80
- const adapters = state.adapters || [];
81
- if (adapters.includes('jira') && !adapters.includes('backlog')) {
82
- return 'jira';
83
- }
84
- }
85
- } catch (_) { /* ignore */ }
86
- return 'backlog';
87
- }
88
-
89
- function isBacklogUrl(s) {
90
- return s.includes('backlog.com/view/') || s.includes('backlogtool.com/view/');
91
- }
92
-
93
- function extractBacklogId(url) {
94
- const match = url.match(/\/view\/([A-Z][A-Z0-9_]+-\d+)/);
95
- return match ? match[1] : null;
96
- }
97
-
98
- // ──────────────────────────────────────────────────────────────
99
- // Version switcher (original behavior)
100
- // ──────────────────────────────────────────────────────────────
101
-
102
- async function switchVersion(versionReq) {
103
- const versionsDir = path.join(AIFLOW_DIR, 'versions');
104
- if (!(await fs.pathExists(versionsDir))) {
105
- console.log(chalk.red('No versions directory found.'));
106
- return;
107
- }
108
-
109
- const available = await fs.readdir(versionsDir);
110
- if (!available.length) {
111
- console.log(chalk.red('No installed versions found.'));
112
- return;
113
- }
114
-
115
- let targetVersion = versionReq;
116
- if (!targetVersion) {
117
- targetVersion = await select({
118
- message: 'Select version to use:',
119
- choices: available.map(v => ({ name: `v${v}`, value: v }))
120
- });
121
- }
122
-
123
- if (!available.includes(targetVersion)) {
124
- console.log(chalk.red(`Version v${targetVersion} is not installed.`));
125
- return;
126
- }
127
-
128
- console.log(chalk.blue(`Switching to v${targetVersion}...`));
129
- const versionDir = path.join(versionsDir, targetVersion);
130
-
131
- const claudeDir = path.join(PROJECT_DIR, '.claude');
132
- await fs.emptyDir(claudeDir);
133
- await fs.copy(path.join(versionDir, 'skills'), path.join(claudeDir, 'skills'), { overwrite: true });
134
-
135
- const rulesDir = path.join(PROJECT_DIR, '.rules');
136
- await fs.emptyDir(rulesDir);
137
- await fs.copy(path.join(versionDir, 'rules'), rulesDir, { overwrite: true });
138
-
139
- const state = await fs.readJson(STATE_FILE);
140
- state.current_version = targetVersion;
141
- await fs.writeJson(STATE_FILE, state);
142
-
143
- console.log(chalk.green(`✓ Switched to v${targetVersion}.`));
144
- }
145
-
146
- // ──────────────────────────────────────────────────────────────
147
- // Backlog context loader
148
- // ──────────────────────────────────────────────────────────────
149
-
150
- async function loadFromBacklog(issueKey, options = {}) {
151
- const creds = await loadCredentials();
152
- const apiKey = process.env.BACKLOG_API_KEY || creds.BACKLOG_API_KEY;
153
- const spaceKey = process.env.BACKLOG_SPACE_KEY || creds.BACKLOG_SPACE_KEY
154
- || process.env.BACKLOG_DOMAIN || creds.BACKLOG_DOMAIN;
155
-
156
- if (!apiKey || !spaceKey) {
157
- console.log(chalk.yellow('⚠ Backlog credentials not set.'));
158
- console.log(chalk.gray('Run: aiflow init --adapter backlog'));
159
- console.log(chalk.gray('Or set environment variables:'));
160
- console.log(chalk.gray(' BACKLOG_API_KEY=your-api-key'));
161
- console.log(chalk.gray(' BACKLOG_SPACE_KEY=your-space (e.g. mycompany)'));
162
- console.log(chalk.gray('\nFalling back to manual entry...\n'));
163
- return await manualContext(issueKey);
164
- }
165
-
166
- // spaceKey can be full domain (mycompany.backlog.com) or just the space (mycompany)
167
- const domain = spaceKey.includes('.') ? spaceKey : `${spaceKey}.backlog.com`;
168
-
169
- console.log(chalk.blue(`Fetching context from Backlog: ${issueKey}...`));
170
-
171
- try {
172
- // Default: description only. Comments loaded only when explicitly requested.
173
- const loadComments = options.withComments || options.commentsLast != null || options.commentsFrom != null;
174
- const [issue, rawComments] = await Promise.all([
175
- fetchBacklogIssue(domain, apiKey, issueKey),
176
- loadComments
177
- ? fetchBacklogComments(domain, apiKey, issueKey)
178
- : Promise.resolve([]),
179
- ]);
180
-
181
- const comments = filterComments(rawComments, options);
182
- const context = buildContextFromBacklog(issue, comments, issueKey, domain);
183
- await saveContext(context, options.save);
184
- printContextSummary(context);
185
- await suggestNextStep(context);
186
- } catch (err) {
187
- console.log(chalk.yellow(`⚠ Could not fetch from Backlog: ${err.message}`));
188
- console.log(chalk.gray('Falling back to manual entry...\n'));
189
- await manualContext(issueKey);
190
- }
191
- }
192
-
193
- /**
194
- * Filter comments array based on CLI options:
195
- * --no-comments → empty array (already handled upstream, but safe here too)
196
- * --comments-last N → last N comments
197
- * --comments-from N → comments starting at index N (0-based)
198
- * (default) → all comments
199
- */
200
- function filterComments(comments, options = {}) {
201
- if (!comments || !comments.length) return [];
202
-
203
- if (options.commentsLast != null) {
204
- const n = Math.max(1, options.commentsLast);
205
- return comments.slice(-n);
206
- }
207
-
208
- if (options.commentsFrom != null) {
209
- const from = Math.max(0, options.commentsFrom);
210
- return comments.slice(from);
211
- }
212
-
213
- return comments;
214
- }
215
-
216
- /**
217
- * Generic Backlog GET helper
218
- */
219
- function backlogGet(url) {
220
- return new Promise((resolve, reject) => {
221
- https.get(url, (res) => {
222
- let data = '';
223
- res.on('data', chunk => data += chunk);
224
- res.on('end', () => {
225
- if (res.statusCode !== 200) {
226
- reject(new Error(`HTTP ${res.statusCode}: ${data}`));
227
- return;
228
- }
229
- try {
230
- resolve(JSON.parse(data));
231
- } catch (e) {
232
- reject(new Error('Invalid JSON response from Backlog'));
233
- }
234
- });
235
- }).on('error', reject);
236
- });
237
- }
238
-
239
- /**
240
- * Fetch issue detail from Backlog REST API
241
- */
242
- function fetchBacklogIssue(domain, apiKey, issueKey) {
243
- return backlogGet(`https://${domain}/api/v2/issues/${issueKey}?apiKey=${apiKey}`);
244
- }
245
-
246
- /**
247
- * Fetch ALL comments of an issue from Backlog REST API (paginated, max 100/page).
248
- */
249
- async function fetchBacklogComments(domain, apiKey, issueKey) {
250
- const all = [];
251
- let offset = 0;
252
- try {
253
- while (true) {
254
- const batch = await backlogGet(
255
- `https://${domain}/api/v2/issues/${issueKey}/comments?apiKey=${apiKey}&count=100&offset=${offset}&order=asc`
256
- );
257
- all.push(...batch);
258
- if (batch.length < 100) break;
259
- offset += 100;
260
- }
261
- } catch (_) {
262
- // comments are optional — don't fail the whole fetch
263
- }
264
- return all;
265
- }
266
-
267
- /**
268
- * Transform Backlog API response + comments to internal context format
269
- */
270
- function buildContextFromBacklog(issue, comments, issueKey, domain) {
271
- const type = detectTaskType(issue);
272
-
273
- // Format comments into readable text for AI context
274
- const formattedComments = (comments || [])
275
- .filter(c => c.content && c.content.trim())
276
- .map(c => {
277
- const author = c.createdUser?.name || 'Unknown';
278
- const date = c.created ? new Date(c.created).toLocaleDateString('vi-VN') : '';
279
- return `[${author} - ${date}]\n${c.content.trim()}`;
280
- });
281
-
282
- const allText = [issue.description || '', ...formattedComments.map(c => c)].join('\n');
283
-
284
- return {
285
- taskId: issueKey,
286
- taskType: type,
287
- title: issue.summary || '',
288
- description: issue.description || '',
289
- status: issue.status?.name || 'Unknown',
290
- priority: issue.priority?.name || 'Unknown',
291
- assignee: issue.assignee?.name || 'Unassigned',
292
-
293
- // Comments from all participants
294
- comments: formattedComments,
295
- commentCount: formattedComments.length,
296
-
297
- // Parse checklist-style acceptance criteria from description
298
- acceptanceCriteria: extractCriteria(issue.description || ''),
299
-
300
- // Related files mentioned anywhere in ticket
301
- context: {
302
- files: extractFiles(allText),
303
- mentionedServices: [],
304
- relatedTickets: extractRelatedTickets(allText),
305
- },
306
-
307
- metadata: {
308
- created: issue.created,
309
- updated: issue.updated,
310
- loadedFrom: 'backlog',
311
- adapter: 'backlog',
312
- sourceUrl: `https://${domain}/view/${issueKey}`,
313
- }
314
- };
315
- }
316
-
317
- // ──────────────────────────────────────────────────────────────
318
- // Jira context loader
319
- // ──────────────────────────────────────────────────────────────
320
-
321
- async function loadFromJira(issueKey, options = {}) {
322
- const creds = await loadCredentials();
323
- const apiToken = process.env.JIRA_API_TOKEN || creds.JIRA_API_TOKEN;
324
- const email = process.env.JIRA_EMAIL || creds.JIRA_EMAIL;
325
- const domain = process.env.JIRA_DOMAIN || creds.JIRA_DOMAIN;
326
-
327
- if (!apiToken || !email || !domain) {
328
- console.log(chalk.yellow('⚠ Jira credentials not set.'));
329
- console.log(chalk.gray('Run: aiflow init --adapter jira'));
330
- console.log(chalk.gray('Or set environment variables: JIRA_API_TOKEN, JIRA_EMAIL, JIRA_DOMAIN'));
331
- console.log(chalk.gray('\nFalling back to manual entry...\n'));
332
- return await manualContext(issueKey);
333
- }
334
-
335
- console.log(chalk.blue(`Fetching context from Jira: ${issueKey}...`));
336
-
337
- try {
338
- const issue = await fetchJiraIssue(domain, email, apiToken, issueKey);
339
- const context = buildContextFromJira(issue, issueKey, domain);
340
- await saveContext(context, options.save);
341
- printContextSummary(context);
342
- await suggestNextStep(context);
343
- } catch (err) {
344
- console.log(chalk.yellow(`⚠ Could not fetch from Jira: ${err.message}`));
345
- console.log(chalk.gray('Falling back to manual entry...\n'));
346
- await manualContext(issueKey);
347
- }
348
- }
349
-
350
- function fetchJiraIssue(domain, email, apiToken, issueKey) {
351
- return new Promise((resolve, reject) => {
352
- const auth = Buffer.from(`${email}:${apiToken}`).toString('base64');
353
- const url = `https://${domain}.atlassian.net/rest/api/3/issue/${issueKey}`;
354
- const options = {
355
- headers: {
356
- 'Authorization': `Basic ${auth}`,
357
- 'Accept': 'application/json'
358
- }
359
- };
360
-
361
- https.get(url, options, (res) => {
362
- let data = '';
363
- res.on('data', chunk => data += chunk);
364
- res.on('end', () => {
365
- if (res.statusCode !== 200) {
366
- reject(new Error(`HTTP ${res.statusCode}: ${data}`));
367
- return;
368
- }
369
- try {
370
- resolve(JSON.parse(data));
371
- } catch (e) {
372
- reject(new Error('Invalid JSON response from Jira'));
373
- }
374
- });
375
- }).on('error', reject);
376
- });
377
- }
378
-
379
- function buildContextFromJira(issue, issueKey, domain) {
380
- const fields = issue.fields || {};
381
- const type = detectTaskTypeFromString(fields.issuetype?.name || '');
382
-
383
- return {
384
- taskId: issueKey,
385
- taskType: type,
386
- title: fields.summary || '',
387
- description: fields.description?.content?.map(block =>
388
- block.content?.map(c => c.text).join('') || ''
389
- ).join('\n') || '',
390
- status: fields.status?.name || 'Unknown',
391
- priority: fields.priority?.name || 'Unknown',
392
- assignee: fields.assignee?.displayName || 'Unassigned',
393
- acceptanceCriteria: extractCriteria(fields.description?.toString() || ''),
394
- context: {
395
- files: [],
396
- relatedTickets: []
397
- },
398
- metadata: {
399
- created: fields.created,
400
- updated: fields.updated,
401
- loadedFrom: 'jira',
402
- adapter: 'jira',
403
- sourceUrl: `https://${domain}.atlassian.net/browse/${issueKey}`
404
- }
405
- };
406
- }
407
-
408
- // ──────────────────────────────────────────────────────────────
409
- // Manual context entry
410
- // ──────────────────────────────────────────────────────────────
411
-
412
- async function manualContext(prefillId = '') {
413
- console.log(chalk.cyan('\nManual Context Entry\n'));
414
-
415
- const taskId = await input({
416
- message: 'Ticket ID (e.g. PROJ-33):',
417
- default: prefillId
418
- });
419
-
420
- const title = await input({ message: 'Title:' });
421
- const description = await input({ message: 'Description (brief):' });
422
- const taskType = await select({
423
- message: 'Task type:',
424
- choices: [
425
- { name: '🐛 Bug Fix', value: 'bug-fix' },
426
- { name: '✨ Feature', value: 'feature' },
427
- { name: '🔍 Investigation', value: 'investigation' },
428
- { name: '♻️ Refactor', value: 'refactor' },
429
- { name: '📊 Impact Analysis', value: 'impact-analysis' }
430
- ]
431
- });
432
-
433
- const context = {
434
- taskId,
435
- taskType,
436
- title,
437
- description,
438
- status: 'In Progress',
439
- acceptanceCriteria: [],
440
- context: { files: [], relatedTickets: [] },
441
- metadata: {
442
- created: new Date().toISOString(),
443
- loadedFrom: 'manual',
444
- adapter: 'manual'
445
- }
446
- };
447
-
448
- await saveContext(context);
449
- printContextSummary(context);
450
- await suggestNextStep(context);
451
- }
452
-
453
- // ──────────────────────────────────────────────────────────────
454
- // Load from JSON file
455
- // ──────────────────────────────────────────────────────────────
456
-
457
- async function loadFromFile(filePath, options = {}) {
458
- if (!(await fs.pathExists(filePath))) {
459
- console.log(chalk.red(`File not found: ${filePath}`));
460
- return;
461
- }
462
- const context = await fs.readJson(filePath);
463
- await saveContext(context, options.save);
464
- printContextSummary(context);
465
- await suggestNextStep(context);
466
- }
467
-
468
- // ──────────────────────────────────────────────────────────────
469
- // Credentials loader (reads .aiflow/credentials.json as fallback)
470
- // ──────────────────────────────────────────────────────────────
471
-
472
- async function loadCredentials() {
473
- const credsFile = path.join(PROJECT_DIR, '.aiflow', 'credentials.json');
474
- if (await fs.pathExists(credsFile)) {
475
- return await fs.readJson(credsFile).catch(() => ({}));
476
- }
477
- return {};
478
- }
479
-
480
- // ──────────────────────────────────────────────────────────────
481
- // Context persistence
482
- // ──────────────────────────────────────────────────────────────
483
-
484
- async function saveContext(context, saveName) {
485
- await fs.ensureDir(CONTEXT_DIR);
486
- await fs.ensureDir(path.join(CONTEXT_DIR, 'history'));
487
-
488
- // Save as current
489
- await fs.writeJson(path.join(CONTEXT_DIR, 'current.json'), context, { spaces: 2 });
490
-
491
- // Save to history
492
- const histFile = path.join(CONTEXT_DIR, 'history', `${context.taskId || 'manual'}.json`);
493
- await fs.writeJson(histFile, context, { spaces: 2 });
494
-
495
- // Save named snapshot if requested
496
- if (saveName) {
497
- const namedFile = path.join(CONTEXT_DIR, 'history', `${saveName}.json`);
498
- await fs.writeJson(namedFile, context, { spaces: 2 });
499
- console.log(chalk.gray(` Context also saved as: ${saveName}`));
500
- }
501
-
502
- // Update state
503
- if (await fs.pathExists(STATE_FILE)) {
504
- const state = await fs.readJson(STATE_FILE);
505
- state.current_context = context.taskId;
506
- state.current_context_type = context.taskType;
507
- await fs.writeJson(STATE_FILE, state);
508
- }
509
- }
510
-
511
- // ──────────────────────────────────────────────────────────────
512
- // Helpers
513
- // ──────────────────────────────────────────────────────────────
514
-
515
- function detectTaskType(issue) {
516
- const name = issue.issueType?.name || issue.type?.name || '';
517
- const summary = issue.summary || '';
518
- return detectTaskTypeFromString(name || summary);
519
- }
520
-
521
- function detectTaskTypeFromString(text) {
522
- const lower = text.toLowerCase();
523
- if (['bug', 'defect', 'バグ'].some(k => lower.includes(k))) return 'bug-fix';
524
- if (['task', 'story', 'feature', 'タスク'].some(k => lower.includes(k))) return 'feature';
525
- if (['research', 'investigation', '調査'].some(k => lower.includes(k))) return 'investigation';
526
- return 'bug-fix'; // default for unknown
527
- }
528
-
529
- function extractCriteria(text) {
530
- const lines = text.split('\n');
531
- return lines
532
- .filter(l => l.trim().match(/^[-*•]|^\d+\./))
533
- .map(l => l.replace(/^[-*•\d.]+\s*/, '').trim())
534
- .filter(l => l.length > 0)
535
- .slice(0, 10);
536
- }
537
-
538
- function extractFiles(text) {
539
- const matches = text.match(/[\w/]+\.(php|js|ts|vue|jsx|tsx|css|html)/g) || [];
540
- return [...new Set(matches)];
541
- }
542
-
543
- function extractRelatedTickets(text) {
544
- const matches = text.match(/[A-Z][A-Z0-9_]+-\d+/g) || [];
545
- return [...new Set(matches)];
546
- }
547
-
548
- function printContextSummary(context) {
549
- console.log(chalk.green('\n✓ Context loaded\n'));
550
- console.log(` ${chalk.white('Ticket:')} ${context.taskId}`);
551
- console.log(` ${chalk.white('Type:')} ${context.taskType}`);
552
- console.log(` ${chalk.white('Title:')} ${context.title.substring(0, 70)}`);
553
- console.log(` ${chalk.white('Status:')} ${context.status}`);
554
- console.log(` ${chalk.white('Assignee:')} ${context.assignee}`);
555
- if (context.description) {
556
- console.log(` ${chalk.white('Desc:')} ${context.description.substring(0, 80)}...`);
557
- }
558
- if (context.acceptanceCriteria?.length) {
559
- console.log(` ${chalk.white('Criteria:')} ${context.acceptanceCriteria.length} item(s)`);
560
- }
561
- if (context.commentCount > 0) {
562
- console.log(` ${chalk.white('Comments:')} ${context.commentCount} comment(s) loaded`);
563
- }
564
- console.log();
565
- }
566
-
567
- async function suggestNextStep(context) {
568
- let aiTools = ['claude']; // Default
569
- try {
570
- if (await fs.pathExists(STATE_FILE)) {
571
- const state = await fs.readJson(STATE_FILE);
572
- if (state.aiTools && state.aiTools.length) {
573
- aiTools = state.aiTools;
574
- }
575
- }
576
- } catch (_) {}
577
-
578
- console.log(chalk.cyan('\nNext Steps:'));
579
-
580
- if (aiTools.includes('claude')) {
581
- console.log(` ${chalk.white('1. Claude:')} Run ${chalk.bold.green('claude start')} in terminal. ${chalk.gray('(Quickest way to start)')}`);
582
- }
583
-
584
- if (aiTools.includes('cursor')) {
585
- console.log(` ${chalk.white('2. Cursor:')} Open Cursor Chat ${chalk.gray('(Ctrl+L)')}.`);
586
- }
587
-
588
- if (aiTools.includes('gemini')) {
589
- console.log(` ${chalk.white('3. Gemini:')} Open Gemini and load project context.`);
590
- }
591
-
592
- console.log(`\n ${chalk.yellow('Note:')} If the AI does not start automatically, type ${chalk.bold.green('"Gate 1"')} or ${chalk.bold.green('"Analyze ticket"')} to begin.`);
593
- console.log();
594
- }
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+ const chalk = require('chalk');
4
+ const https = require('https');
5
+ const { select, input } = require('@inquirer/prompts');
6
+
7
+ const PROJECT_DIR = process.cwd();
8
+ const AIFLOW_DIR = path.join(PROJECT_DIR, '.aiflow');
9
+ const STATE_FILE = path.join(AIFLOW_DIR, 'state.json');
10
+ const CONTEXT_DIR = path.join(PROJECT_DIR, '.aiflow', 'context');
11
+
12
+ // ──────────────────────────────────────────────────────────────
13
+ // Entry point
14
+ // ──────────────────────────────────────────────────────────────
15
+
16
+ module.exports = async function use(target, options = {}) {
17
+ if (!(await fs.pathExists(STATE_FILE))) {
18
+ console.log(chalk.red('Project is not initialized. Run `aiflow init` first.'));
19
+ return;
20
+ }
21
+
22
+ // ── Manual entry ──
23
+ if (options.manual) {
24
+ return await manualContext();
25
+ }
26
+
27
+ // ── Load from local file ──
28
+ if (options.file) {
29
+ return await loadFromFile(options.file, options);
30
+ }
31
+
32
+ // ── No target → version switcher ──
33
+ if (!target) {
34
+ return await switchVersion();
35
+ }
36
+
37
+ // ── Detect target type and dispatch ──
38
+ if (isVersion(target)) {
39
+ return await switchVersion(target);
40
+ }
41
+
42
+ if (isTicketId(target)) {
43
+ const adapter = await resolveTicketAdapter();
44
+ if (adapter === 'jira') return await loadFromJira(target, options);
45
+ return await loadFromBacklog(target, options);
46
+ }
47
+
48
+ if (isBacklogUrl(target)) {
49
+ const id = extractBacklogId(target);
50
+ return await loadFromBacklog(id, options);
51
+ }
52
+
53
+ // ── Fallback: try Backlog then manual ──
54
+ console.log(chalk.yellow(`Cannot detect format for: ${target}`));
55
+ console.log(chalk.gray('Supported: PROJ-33, APP-123, version number, --manual'));
56
+ return await manualContext();
57
+ };
58
+
59
+ // ──────────────────────────────────────────────────────────────
60
+ // Detectors
61
+ // ──────────────────────────────────────────────────────────────
62
+
63
+ function isVersion(s) {
64
+ return /^\d+\.\d+\.\d+$/.test(s);
65
+ }
66
+
67
+ /** Both Backlog and Jira use the same PROJECT-NUMBER format. */
68
+ function isTicketId(s) {
69
+ return /^[A-Z][A-Z0-9_]+-\d+$/.test(s);
70
+ }
71
+
72
+ /**
73
+ * Determine which adapter to use for a ticket ID based on configured adapters
74
+ * in state.json. Falls back to backlog if both or neither are configured.
75
+ */
76
+ async function resolveTicketAdapter() {
77
+ try {
78
+ if (await fs.pathExists(STATE_FILE)) {
79
+ const state = await fs.readJson(STATE_FILE);
80
+ const adapters = state.adapters || [];
81
+ if (adapters.includes('jira') && !adapters.includes('backlog')) {
82
+ return 'jira';
83
+ }
84
+ }
85
+ } catch (_) { /* ignore */ }
86
+ return 'backlog';
87
+ }
88
+
89
+ function isBacklogUrl(s) {
90
+ return s.includes('backlog.com/view/') || s.includes('backlogtool.com/view/');
91
+ }
92
+
93
+ function extractBacklogId(url) {
94
+ const match = url.match(/\/view\/([A-Z][A-Z0-9_]+-\d+)/);
95
+ return match ? match[1] : null;
96
+ }
97
+
98
+ // ──────────────────────────────────────────────────────────────
99
+ // Version switcher (original behavior)
100
+ // ──────────────────────────────────────────────────────────────
101
+
102
+ async function switchVersion(versionReq) {
103
+ const versionsDir = path.join(AIFLOW_DIR, 'versions');
104
+ if (!(await fs.pathExists(versionsDir))) {
105
+ console.log(chalk.red('No versions directory found.'));
106
+ return;
107
+ }
108
+
109
+ const available = await fs.readdir(versionsDir);
110
+ if (!available.length) {
111
+ console.log(chalk.red('No installed versions found.'));
112
+ return;
113
+ }
114
+
115
+ let targetVersion = versionReq;
116
+ if (!targetVersion) {
117
+ targetVersion = await select({
118
+ message: 'Select version to use:',
119
+ choices: available.map(v => ({ name: `v${v}`, value: v }))
120
+ });
121
+ }
122
+
123
+ if (!available.includes(targetVersion)) {
124
+ console.log(chalk.red(`Version v${targetVersion} is not installed.`));
125
+ return;
126
+ }
127
+
128
+ console.log(chalk.blue(`Switching to v${targetVersion}...`));
129
+ const versionDir = path.join(versionsDir, targetVersion);
130
+
131
+ const claudeDir = path.join(PROJECT_DIR, '.claude');
132
+ await fs.emptyDir(claudeDir);
133
+ await fs.copy(path.join(versionDir, 'skills'), path.join(claudeDir, 'skills'), { overwrite: true });
134
+
135
+ const rulesDir = path.join(PROJECT_DIR, '.rules');
136
+ await fs.emptyDir(rulesDir);
137
+ await fs.copy(path.join(versionDir, 'rules'), rulesDir, { overwrite: true });
138
+
139
+ const state = await fs.readJson(STATE_FILE);
140
+ state.current_version = targetVersion;
141
+ await fs.writeJson(STATE_FILE, state);
142
+
143
+ console.log(chalk.green(`✓ Switched to v${targetVersion}.`));
144
+ }
145
+
146
+ // ──────────────────────────────────────────────────────────────
147
+ // Backlog context loader
148
+ // ──────────────────────────────────────────────────────────────
149
+
150
+ async function loadFromBacklog(issueKey, options = {}) {
151
+ const creds = await loadCredentials();
152
+ const apiKey = process.env.BACKLOG_API_KEY || creds.BACKLOG_API_KEY;
153
+ const spaceKey = process.env.BACKLOG_SPACE_KEY || creds.BACKLOG_SPACE_KEY
154
+ || process.env.BACKLOG_DOMAIN || creds.BACKLOG_DOMAIN;
155
+
156
+ if (!apiKey || !spaceKey) {
157
+ console.log(chalk.yellow('⚠ Backlog credentials not set.'));
158
+ console.log(chalk.gray('Run: aiflow init --adapter backlog'));
159
+ console.log(chalk.gray('Or set environment variables:'));
160
+ console.log(chalk.gray(' BACKLOG_API_KEY=your-api-key'));
161
+ console.log(chalk.gray(' BACKLOG_SPACE_KEY=your-space (e.g. mycompany)'));
162
+ console.log(chalk.gray('\nFalling back to manual entry...\n'));
163
+ return await manualContext(issueKey);
164
+ }
165
+
166
+ // spaceKey can be full domain (mycompany.backlog.com) or just the space (mycompany)
167
+ const domain = spaceKey.includes('.') ? spaceKey : `${spaceKey}.backlog.com`;
168
+
169
+ console.log(chalk.blue(`Fetching context from Backlog: ${issueKey}...`));
170
+
171
+ try {
172
+ // Default: description only. Comments loaded only when explicitly requested.
173
+ const loadComments = options.withComments || options.commentsLast != null || options.commentsFrom != null;
174
+ const [issue, rawComments] = await Promise.all([
175
+ fetchBacklogIssue(domain, apiKey, issueKey),
176
+ loadComments
177
+ ? fetchBacklogComments(domain, apiKey, issueKey)
178
+ : Promise.resolve([]),
179
+ ]);
180
+
181
+ const comments = filterComments(rawComments, options);
182
+ const context = buildContextFromBacklog(issue, comments, issueKey, domain);
183
+ await saveContext(context, options.save);
184
+ printContextSummary(context);
185
+ await suggestNextStep(context);
186
+ } catch (err) {
187
+ console.log(chalk.yellow(`⚠ Could not fetch from Backlog: ${err.message}`));
188
+ console.log(chalk.gray('Falling back to manual entry...\n'));
189
+ await manualContext(issueKey);
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Filter comments array based on CLI options:
195
+ * --no-comments → empty array (already handled upstream, but safe here too)
196
+ * --comments-last N → last N comments
197
+ * --comments-from N → comments starting at index N (0-based)
198
+ * (default) → all comments
199
+ */
200
+ function filterComments(comments, options = {}) {
201
+ if (!comments || !comments.length) return [];
202
+
203
+ if (options.commentsLast != null) {
204
+ const n = Math.max(1, options.commentsLast);
205
+ return comments.slice(-n);
206
+ }
207
+
208
+ if (options.commentsFrom != null) {
209
+ const from = Math.max(0, options.commentsFrom);
210
+ return comments.slice(from);
211
+ }
212
+
213
+ return comments;
214
+ }
215
+
216
+ /**
217
+ * Generic Backlog GET helper
218
+ */
219
+ function backlogGet(url) {
220
+ return new Promise((resolve, reject) => {
221
+ https.get(url, (res) => {
222
+ let data = '';
223
+ res.on('data', chunk => data += chunk);
224
+ res.on('end', () => {
225
+ if (res.statusCode !== 200) {
226
+ reject(new Error(`HTTP ${res.statusCode}: ${data}`));
227
+ return;
228
+ }
229
+ try {
230
+ resolve(JSON.parse(data));
231
+ } catch (e) {
232
+ reject(new Error('Invalid JSON response from Backlog'));
233
+ }
234
+ });
235
+ }).on('error', reject);
236
+ });
237
+ }
238
+
239
+ /**
240
+ * Fetch issue detail from Backlog REST API
241
+ */
242
+ function fetchBacklogIssue(domain, apiKey, issueKey) {
243
+ return backlogGet(`https://${domain}/api/v2/issues/${issueKey}?apiKey=${apiKey}`);
244
+ }
245
+
246
+ /**
247
+ * Fetch ALL comments of an issue from Backlog REST API (paginated, max 100/page).
248
+ */
249
+ async function fetchBacklogComments(domain, apiKey, issueKey) {
250
+ const all = [];
251
+ let offset = 0;
252
+ try {
253
+ while (true) {
254
+ const batch = await backlogGet(
255
+ `https://${domain}/api/v2/issues/${issueKey}/comments?apiKey=${apiKey}&count=100&offset=${offset}&order=asc`
256
+ );
257
+ all.push(...batch);
258
+ if (batch.length < 100) break;
259
+ offset += 100;
260
+ }
261
+ } catch (_) {
262
+ // comments are optional — don't fail the whole fetch
263
+ }
264
+ return all;
265
+ }
266
+
267
+ /**
268
+ * Transform Backlog API response + comments to internal context format
269
+ */
270
+ function buildContextFromBacklog(issue, comments, issueKey, domain) {
271
+ const type = detectTaskType(issue);
272
+
273
+ // Format comments into readable text for AI context
274
+ const formattedComments = (comments || [])
275
+ .filter(c => c.content && c.content.trim())
276
+ .map(c => {
277
+ const author = c.createdUser?.name || 'Unknown';
278
+ const date = c.created ? new Date(c.created).toLocaleDateString('vi-VN') : '';
279
+ return `[${author} - ${date}]\n${c.content.trim()}`;
280
+ });
281
+
282
+ const allText = [issue.description || '', ...formattedComments.map(c => c)].join('\n');
283
+
284
+ return {
285
+ taskId: issueKey,
286
+ taskType: type,
287
+ title: issue.summary || '',
288
+ description: issue.description || '',
289
+ status: issue.status?.name || 'Unknown',
290
+ priority: issue.priority?.name || 'Unknown',
291
+ assignee: issue.assignee?.name || 'Unassigned',
292
+
293
+ // Comments from all participants
294
+ comments: formattedComments,
295
+ commentCount: formattedComments.length,
296
+
297
+ // Parse checklist-style acceptance criteria from description
298
+ acceptanceCriteria: extractCriteria(issue.description || ''),
299
+
300
+ // Related files mentioned anywhere in ticket
301
+ context: {
302
+ files: extractFiles(allText),
303
+ mentionedServices: [],
304
+ relatedTickets: extractRelatedTickets(allText),
305
+ },
306
+
307
+ metadata: {
308
+ created: issue.created,
309
+ updated: issue.updated,
310
+ loadedFrom: 'backlog',
311
+ adapter: 'backlog',
312
+ sourceUrl: `https://${domain}/view/${issueKey}`,
313
+ }
314
+ };
315
+ }
316
+
317
+ // ──────────────────────────────────────────────────────────────
318
+ // Jira context loader
319
+ // ──────────────────────────────────────────────────────────────
320
+
321
+ async function loadFromJira(issueKey, options = {}) {
322
+ const creds = await loadCredentials();
323
+ const apiToken = process.env.JIRA_API_TOKEN || creds.JIRA_API_TOKEN;
324
+ const email = process.env.JIRA_EMAIL || creds.JIRA_EMAIL;
325
+ const domain = process.env.JIRA_DOMAIN || creds.JIRA_DOMAIN;
326
+
327
+ if (!apiToken || !email || !domain) {
328
+ console.log(chalk.yellow('⚠ Jira credentials not set.'));
329
+ console.log(chalk.gray('Run: aiflow init --adapter jira'));
330
+ console.log(chalk.gray('Or set environment variables: JIRA_API_TOKEN, JIRA_EMAIL, JIRA_DOMAIN'));
331
+ console.log(chalk.gray('\nFalling back to manual entry...\n'));
332
+ return await manualContext(issueKey);
333
+ }
334
+
335
+ console.log(chalk.blue(`Fetching context from Jira: ${issueKey}...`));
336
+
337
+ try {
338
+ const issue = await fetchJiraIssue(domain, email, apiToken, issueKey);
339
+ const context = buildContextFromJira(issue, issueKey, domain);
340
+ await saveContext(context, options.save);
341
+ printContextSummary(context);
342
+ await suggestNextStep(context);
343
+ } catch (err) {
344
+ console.log(chalk.yellow(`⚠ Could not fetch from Jira: ${err.message}`));
345
+ console.log(chalk.gray('Falling back to manual entry...\n'));
346
+ await manualContext(issueKey);
347
+ }
348
+ }
349
+
350
+ function fetchJiraIssue(domain, email, apiToken, issueKey) {
351
+ return new Promise((resolve, reject) => {
352
+ const auth = Buffer.from(`${email}:${apiToken}`).toString('base64');
353
+ const url = `https://${domain}.atlassian.net/rest/api/3/issue/${issueKey}`;
354
+ const options = {
355
+ headers: {
356
+ 'Authorization': `Basic ${auth}`,
357
+ 'Accept': 'application/json'
358
+ }
359
+ };
360
+
361
+ https.get(url, options, (res) => {
362
+ let data = '';
363
+ res.on('data', chunk => data += chunk);
364
+ res.on('end', () => {
365
+ if (res.statusCode !== 200) {
366
+ reject(new Error(`HTTP ${res.statusCode}: ${data}`));
367
+ return;
368
+ }
369
+ try {
370
+ resolve(JSON.parse(data));
371
+ } catch (e) {
372
+ reject(new Error('Invalid JSON response from Jira'));
373
+ }
374
+ });
375
+ }).on('error', reject);
376
+ });
377
+ }
378
+
379
+ function buildContextFromJira(issue, issueKey, domain) {
380
+ const fields = issue.fields || {};
381
+ const type = detectTaskTypeFromString(fields.issuetype?.name || '');
382
+
383
+ return {
384
+ taskId: issueKey,
385
+ taskType: type,
386
+ title: fields.summary || '',
387
+ description: fields.description?.content?.map(block =>
388
+ block.content?.map(c => c.text).join('') || ''
389
+ ).join('\n') || '',
390
+ status: fields.status?.name || 'Unknown',
391
+ priority: fields.priority?.name || 'Unknown',
392
+ assignee: fields.assignee?.displayName || 'Unassigned',
393
+ acceptanceCriteria: extractCriteria(fields.description?.toString() || ''),
394
+ context: {
395
+ files: [],
396
+ relatedTickets: []
397
+ },
398
+ metadata: {
399
+ created: fields.created,
400
+ updated: fields.updated,
401
+ loadedFrom: 'jira',
402
+ adapter: 'jira',
403
+ sourceUrl: `https://${domain}.atlassian.net/browse/${issueKey}`
404
+ }
405
+ };
406
+ }
407
+
408
+ // ──────────────────────────────────────────────────────────────
409
+ // Manual context entry
410
+ // ──────────────────────────────────────────────────────────────
411
+
412
+ async function manualContext(prefillId = '') {
413
+ console.log(chalk.cyan('\nManual Context Entry\n'));
414
+
415
+ const taskId = await input({
416
+ message: 'Ticket ID (e.g. PROJ-33):',
417
+ default: prefillId
418
+ });
419
+
420
+ const title = await input({ message: 'Title:' });
421
+ const description = await input({ message: 'Description (brief):' });
422
+ const taskType = await select({
423
+ message: 'Task type:',
424
+ choices: [
425
+ { name: '🐛 Bug Fix', value: 'bug-fix' },
426
+ { name: '✨ Feature', value: 'feature' },
427
+ { name: '🔍 Investigation', value: 'investigation' },
428
+ { name: '♻️ Refactor', value: 'refactor' },
429
+ { name: '📊 Impact Analysis', value: 'impact-analysis' }
430
+ ]
431
+ });
432
+
433
+ const context = {
434
+ taskId,
435
+ taskType,
436
+ title,
437
+ description,
438
+ status: 'In Progress',
439
+ acceptanceCriteria: [],
440
+ context: { files: [], relatedTickets: [] },
441
+ metadata: {
442
+ created: new Date().toISOString(),
443
+ loadedFrom: 'manual',
444
+ adapter: 'manual'
445
+ }
446
+ };
447
+
448
+ await saveContext(context);
449
+ printContextSummary(context);
450
+ await suggestNextStep(context);
451
+ }
452
+
453
+ // ──────────────────────────────────────────────────────────────
454
+ // Load from JSON file
455
+ // ──────────────────────────────────────────────────────────────
456
+
457
+ async function loadFromFile(filePath, options = {}) {
458
+ if (!(await fs.pathExists(filePath))) {
459
+ console.log(chalk.red(`File not found: ${filePath}`));
460
+ return;
461
+ }
462
+ const context = await fs.readJson(filePath);
463
+ await saveContext(context, options.save);
464
+ printContextSummary(context);
465
+ await suggestNextStep(context);
466
+ }
467
+
468
+ // ──────────────────────────────────────────────────────────────
469
+ // Credentials loader (reads .aiflow/credentials.json as fallback)
470
+ // ──────────────────────────────────────────────────────────────
471
+
472
+ async function loadCredentials() {
473
+ const credsFile = path.join(PROJECT_DIR, '.aiflow', 'credentials.json');
474
+ if (await fs.pathExists(credsFile)) {
475
+ return await fs.readJson(credsFile).catch(() => ({}));
476
+ }
477
+ return {};
478
+ }
479
+
480
+ // ──────────────────────────────────────────────────────────────
481
+ // Context persistence
482
+ // ──────────────────────────────────────────────────────────────
483
+
484
+ async function saveContext(context, saveName) {
485
+ await fs.ensureDir(CONTEXT_DIR);
486
+ await fs.ensureDir(path.join(CONTEXT_DIR, 'history'));
487
+
488
+ // Save as current
489
+ await fs.writeJson(path.join(CONTEXT_DIR, 'current.json'), context, { spaces: 2 });
490
+
491
+ // Save to history
492
+ const histFile = path.join(CONTEXT_DIR, 'history', `${context.taskId || 'manual'}.json`);
493
+ await fs.writeJson(histFile, context, { spaces: 2 });
494
+
495
+ // Save named snapshot if requested
496
+ if (saveName) {
497
+ const namedFile = path.join(CONTEXT_DIR, 'history', `${saveName}.json`);
498
+ await fs.writeJson(namedFile, context, { spaces: 2 });
499
+ console.log(chalk.gray(` Context also saved as: ${saveName}`));
500
+ }
501
+
502
+ // Update state
503
+ if (await fs.pathExists(STATE_FILE)) {
504
+ const state = await fs.readJson(STATE_FILE);
505
+ state.current_context = context.taskId;
506
+ state.current_context_type = context.taskType;
507
+ await fs.writeJson(STATE_FILE, state);
508
+ }
509
+ }
510
+
511
+ // ──────────────────────────────────────────────────────────────
512
+ // Helpers
513
+ // ──────────────────────────────────────────────────────────────
514
+
515
+ function detectTaskType(issue) {
516
+ const name = issue.issueType?.name || issue.type?.name || '';
517
+ const summary = issue.summary || '';
518
+ return detectTaskTypeFromString(name || summary);
519
+ }
520
+
521
+ function detectTaskTypeFromString(text) {
522
+ const lower = text.toLowerCase();
523
+ if (['bug', 'defect', 'バグ'].some(k => lower.includes(k))) return 'bug-fix';
524
+ if (['task', 'story', 'feature', 'タスク'].some(k => lower.includes(k))) return 'feature';
525
+ if (['research', 'investigation', '調査'].some(k => lower.includes(k))) return 'investigation';
526
+ return 'bug-fix'; // default for unknown
527
+ }
528
+
529
+ function extractCriteria(text) {
530
+ const lines = text.split('\n');
531
+ return lines
532
+ .filter(l => l.trim().match(/^[-*•]|^\d+\./))
533
+ .map(l => l.replace(/^[-*•\d.]+\s*/, '').trim())
534
+ .filter(l => l.length > 0)
535
+ .slice(0, 10);
536
+ }
537
+
538
+ function extractFiles(text) {
539
+ const matches = text.match(/[\w/]+\.(php|js|ts|vue|jsx|tsx|css|html)/g) || [];
540
+ return [...new Set(matches)];
541
+ }
542
+
543
+ function extractRelatedTickets(text) {
544
+ const matches = text.match(/[A-Z][A-Z0-9_]+-\d+/g) || [];
545
+ return [...new Set(matches)];
546
+ }
547
+
548
+ function printContextSummary(context) {
549
+ console.log(chalk.green('\n✓ Context loaded\n'));
550
+ console.log(` ${chalk.white('Ticket:')} ${context.taskId}`);
551
+ console.log(` ${chalk.white('Type:')} ${context.taskType}`);
552
+ console.log(` ${chalk.white('Title:')} ${context.title.substring(0, 70)}`);
553
+ console.log(` ${chalk.white('Status:')} ${context.status}`);
554
+ console.log(` ${chalk.white('Assignee:')} ${context.assignee}`);
555
+ if (context.description) {
556
+ console.log(` ${chalk.white('Desc:')} ${context.description.substring(0, 80)}...`);
557
+ }
558
+ if (context.acceptanceCriteria?.length) {
559
+ console.log(` ${chalk.white('Criteria:')} ${context.acceptanceCriteria.length} item(s)`);
560
+ }
561
+ if (context.commentCount > 0) {
562
+ console.log(` ${chalk.white('Comments:')} ${context.commentCount} comment(s) loaded`);
563
+ }
564
+ console.log();
565
+ }
566
+
567
+ async function suggestNextStep(context) {
568
+ let aiTools = ['claude']; // Default
569
+ try {
570
+ if (await fs.pathExists(STATE_FILE)) {
571
+ const state = await fs.readJson(STATE_FILE);
572
+ if (state.aiTools && state.aiTools.length) {
573
+ aiTools = state.aiTools;
574
+ }
575
+ }
576
+ } catch (_) {}
577
+
578
+ console.log(chalk.cyan('\nNext Steps:'));
579
+
580
+ if (aiTools.includes('claude')) {
581
+ console.log(` ${chalk.white('1. Claude:')} Run ${chalk.bold.green('claude start')} in terminal. ${chalk.gray('(Quickest way to start)')}`);
582
+ }
583
+
584
+ if (aiTools.includes('cursor')) {
585
+ console.log(` ${chalk.white('2. Cursor:')} Open Cursor Chat ${chalk.gray('(Ctrl+L)')}.`);
586
+ }
587
+
588
+ if (aiTools.includes('gemini')) {
589
+ console.log(` ${chalk.white('3. Gemini:')} Run ${chalk.bold.green('gemini')} then type ${chalk.bold.green('"start"')} or ${chalk.bold.green('"Gate 1"')}.`);
590
+ }
591
+
592
+ console.log(`\n ${chalk.yellow('Note:')} If the AI does not start automatically, type ${chalk.bold.green('"Gate 1"')} or ${chalk.bold.green('"Analyze ticket"')} to begin.`);
593
+ console.log();
594
+ }