@relipa/ai-flow-kit 0.0.7-beta.2 → 0.0.8-beta.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/scripts/use.js CHANGED
@@ -1,63 +1,67 @@
1
- const fs = require('fs-extra');
2
- const path = require('path');
3
- const os = require('os');
4
- const chalk = require('chalk');
5
- const https = require('https');
6
- const { select, input } = require('@inquirer/prompts');
1
+ const fs = require("fs-extra");
2
+ const path = require("path");
3
+ const os = require("os");
4
+ const chalk = require("chalk");
5
+ const https = require("https");
6
+ const { select, input } = require("@inquirer/prompts");
7
7
 
8
8
  const PROJECT_DIR = process.cwd();
9
- const AIFLOW_DIR = path.join(PROJECT_DIR, '.aiflow');
10
- const STATE_FILE = path.join(AIFLOW_DIR, 'state.json');
11
- const CONTEXT_DIR = path.join(PROJECT_DIR, '.aiflow', 'context');
9
+ const AIFLOW_DIR = path.join(PROJECT_DIR, ".aiflow");
10
+ const STATE_FILE = path.join(AIFLOW_DIR, "state.json");
11
+ const CONTEXT_DIR = path.join(PROJECT_DIR, ".aiflow", "context");
12
12
 
13
13
  // ──────────────────────────────────────────────────────────────
14
14
  // Entry point
15
15
  // ──────────────────────────────────────────────────────────────
16
16
 
17
17
  module.exports = async function use(target, options = {}) {
18
- if (!(await fs.pathExists(STATE_FILE))) {
19
- console.log(chalk.red('Project is not initialized. Run `aiflow init` first.'));
20
- return;
21
- }
22
-
23
- // ── GitNexus index notification (all AI tools see this) ──────
24
- await checkGitNexusIndexStatus();
25
-
26
- // ── Manual entry ──
27
- if (options.manual) {
28
- return await manualContext();
29
- }
30
-
31
- // ── Load from local file ──
32
- if (options.file) {
33
- return await loadFromFile(options.file, options);
34
- }
35
-
36
- // ── No target → version switcher ──
37
- if (!target) {
38
- return await switchVersion();
39
- }
40
-
41
- // ── Detect target type and dispatch ──
42
- if (isVersion(target)) {
43
- return await switchVersion(target);
44
- }
45
-
46
- if (isTicketId(target)) {
47
- const adapter = await resolveTicketAdapter();
48
- if (adapter === 'jira') return await loadFromJira(target, options);
49
- return await loadFromBacklog(target, options);
50
- }
51
-
52
- if (isBacklogUrl(target)) {
53
- const id = extractBacklogId(target);
54
- return await loadFromBacklog(id, options);
55
- }
56
-
57
- // ── Fallback: try Backlog then manual ──
58
- console.log(chalk.yellow(`Cannot detect format for: ${target}`));
59
- console.log(chalk.gray('Supported: PROJ-33, APP-123, version number, --manual'));
18
+ if (!(await fs.pathExists(STATE_FILE))) {
19
+ console.log(
20
+ chalk.red("Project is not initialized. Run `aiflow init` first."),
21
+ );
22
+ return;
23
+ }
24
+
25
+ // ── GitNexus index notification (all AI tools see this) ──────
26
+ await checkGitNexusIndexStatus();
27
+
28
+ // ── Manual entry ──
29
+ if (options.manual) {
60
30
  return await manualContext();
31
+ }
32
+
33
+ // ── Load from local file ──
34
+ if (options.file) {
35
+ return await loadFromFile(options.file, options);
36
+ }
37
+
38
+ // ── No target → version switcher ──
39
+ if (!target) {
40
+ return await switchVersion();
41
+ }
42
+
43
+ // ── Detect target type and dispatch ──
44
+ if (isVersion(target)) {
45
+ return await switchVersion(target);
46
+ }
47
+
48
+ if (isTicketId(target)) {
49
+ const adapter = await resolveTicketAdapter();
50
+ if (adapter === "jira") return await loadFromJira(target, options);
51
+ return await loadFromBacklog(target, options);
52
+ }
53
+
54
+ if (isBacklogUrl(target)) {
55
+ const id = extractBacklogId(target);
56
+ return await loadFromBacklog(id, options);
57
+ }
58
+
59
+ // ── Fallback: try Backlog then manual ──
60
+ console.log(chalk.yellow(`Cannot detect format for: ${target}`));
61
+ console.log(
62
+ chalk.gray("Supported: PROJ-33, APP-123, version number, --manual"),
63
+ );
64
+ return await manualContext();
61
65
  };
62
66
 
63
67
  // ──────────────────────────────────────────────────────────────
@@ -65,12 +69,12 @@ module.exports = async function use(target, options = {}) {
65
69
  // ──────────────────────────────────────────────────────────────
66
70
 
67
71
  function isVersion(s) {
68
- return /^\d+\.\d+\.\d+$/.test(s);
72
+ return /^\d+\.\d+\.\d+$/.test(s);
69
73
  }
70
74
 
71
75
  /** Both Backlog and Jira use the same PROJECT-NUMBER format. */
72
76
  function isTicketId(s) {
73
- return /^[A-Z][A-Z0-9_]+-\d+$/.test(s);
77
+ return /^[A-Z][A-Z0-9_]+-\d+$/.test(s);
74
78
  }
75
79
 
76
80
  /**
@@ -78,25 +82,27 @@ function isTicketId(s) {
78
82
  * in state.json. Falls back to backlog if both or neither are configured.
79
83
  */
80
84
  async function resolveTicketAdapter() {
81
- try {
82
- if (await fs.pathExists(STATE_FILE)) {
83
- const state = await fs.readJson(STATE_FILE);
84
- const adapters = state.adapters || [];
85
- if (adapters.includes('jira') && !adapters.includes('backlog')) {
86
- return 'jira';
87
- }
88
- }
89
- } catch (_) { /* ignore */ }
90
- return 'backlog';
85
+ try {
86
+ if (await fs.pathExists(STATE_FILE)) {
87
+ const state = await fs.readJson(STATE_FILE);
88
+ const adapters = state.adapters || [];
89
+ if (adapters.includes("jira") && !adapters.includes("backlog")) {
90
+ return "jira";
91
+ }
92
+ }
93
+ } catch (_) {
94
+ /* ignore */
95
+ }
96
+ return "backlog";
91
97
  }
92
98
 
93
99
  function isBacklogUrl(s) {
94
- return s.includes('backlog.com/view/') || s.includes('backlogtool.com/view/');
100
+ return s.includes("backlog.com/view/") || s.includes("backlogtool.com/view/");
95
101
  }
96
102
 
97
103
  function extractBacklogId(url) {
98
- const match = url.match(/\/view\/([A-Z][A-Z0-9_]+-\d+)/);
99
- return match ? match[1] : null;
104
+ const match = url.match(/\/view\/([A-Z][A-Z0-9_]+-\d+)/);
105
+ return match ? match[1] : null;
100
106
  }
101
107
 
102
108
  // ──────────────────────────────────────────────────────────────
@@ -104,47 +110,51 @@ function extractBacklogId(url) {
104
110
  // ──────────────────────────────────────────────────────────────
105
111
 
106
112
  async function switchVersion(versionReq) {
107
- const versionsDir = path.join(AIFLOW_DIR, 'versions');
108
- if (!(await fs.pathExists(versionsDir))) {
109
- console.log(chalk.red('No versions directory found.'));
110
- return;
111
- }
112
-
113
- const available = await fs.readdir(versionsDir);
114
- if (!available.length) {
115
- console.log(chalk.red('No installed versions found.'));
116
- return;
117
- }
118
-
119
- let targetVersion = versionReq;
120
- if (!targetVersion) {
121
- targetVersion = await select({
122
- message: 'Select version to use:',
123
- choices: available.map(v => ({ name: `v${v}`, value: v }))
124
- });
125
- }
113
+ const versionsDir = path.join(AIFLOW_DIR, "versions");
114
+ if (!(await fs.pathExists(versionsDir))) {
115
+ console.log(chalk.red("No versions directory found."));
116
+ return;
117
+ }
118
+
119
+ const available = await fs.readdir(versionsDir);
120
+ if (!available.length) {
121
+ console.log(chalk.red("No installed versions found."));
122
+ return;
123
+ }
124
+
125
+ let targetVersion = versionReq;
126
+ if (!targetVersion) {
127
+ targetVersion = await select({
128
+ message: "Select version to use:",
129
+ choices: available.map((v) => ({ name: `v${v}`, value: v })),
130
+ });
131
+ }
126
132
 
127
- if (!available.includes(targetVersion)) {
128
- console.log(chalk.red(`Version v${targetVersion} is not installed.`));
129
- return;
130
- }
133
+ if (!available.includes(targetVersion)) {
134
+ console.log(chalk.red(`Version v${targetVersion} is not installed.`));
135
+ return;
136
+ }
131
137
 
132
- console.log(chalk.blue(`Switching to v${targetVersion}...`));
133
- const versionDir = path.join(versionsDir, targetVersion);
138
+ console.log(chalk.blue(`Switching to v${targetVersion}...`));
139
+ const versionDir = path.join(versionsDir, targetVersion);
134
140
 
135
- const claudeDir = path.join(PROJECT_DIR, '.claude');
136
- await fs.emptyDir(claudeDir);
137
- await fs.copy(path.join(versionDir, 'skills'), path.join(claudeDir, 'skills'), { overwrite: true });
141
+ const claudeDir = path.join(PROJECT_DIR, ".claude");
142
+ await fs.emptyDir(claudeDir);
143
+ await fs.copy(
144
+ path.join(versionDir, "skills"),
145
+ path.join(claudeDir, "skills"),
146
+ { overwrite: true },
147
+ );
138
148
 
139
- const rulesDir = path.join(PROJECT_DIR, '.rules');
140
- await fs.emptyDir(rulesDir);
141
- await fs.copy(path.join(versionDir, 'rules'), rulesDir, { overwrite: true });
149
+ const rulesDir = path.join(PROJECT_DIR, ".rules");
150
+ await fs.emptyDir(rulesDir);
151
+ await fs.copy(path.join(versionDir, "rules"), rulesDir, { overwrite: true });
142
152
 
143
- const state = await fs.readJson(STATE_FILE);
144
- state.current_version = targetVersion;
145
- await fs.writeJson(STATE_FILE, state);
153
+ const state = await fs.readJson(STATE_FILE);
154
+ state.current_version = targetVersion;
155
+ await fs.writeJson(STATE_FILE, state);
146
156
 
147
- console.log(chalk.green(`✓ Switched to v${targetVersion}.`));
157
+ console.log(chalk.green(`✓ Switched to v${targetVersion}.`));
148
158
  }
149
159
 
150
160
  // ──────────────────────────────────────────────────────────────
@@ -152,47 +162,72 @@ async function switchVersion(versionReq) {
152
162
  // ──────────────────────────────────────────────────────────────
153
163
 
154
164
  async function loadFromBacklog(issueKey, options = {}) {
155
- const creds = await loadCredentials();
156
- const apiKey = process.env.BACKLOG_API_KEY || creds.BACKLOG_API_KEY;
157
- const spaceKey = process.env.BACKLOG_SPACE_KEY || creds.BACKLOG_SPACE_KEY
158
- || process.env.BACKLOG_DOMAIN || creds.BACKLOG_DOMAIN;
159
-
160
- if (!apiKey || !spaceKey) {
161
- console.log(chalk.yellow('⚠ Backlog credentials not set.'));
162
- console.log(chalk.gray('Run: aiflow init --adapter backlog'));
163
- console.log(chalk.gray('Or set environment variables:'));
164
- console.log(chalk.gray(' BACKLOG_API_KEY=your-api-key'));
165
- console.log(chalk.gray(' BACKLOG_SPACE_KEY=your-space (e.g. mycompany)'));
166
- console.log(chalk.gray('\nFalling back to manual entry...\n'));
167
- return await manualContext(issueKey);
165
+ const creds = await loadCredentials();
166
+ const apiKey = process.env.BACKLOG_API_KEY || creds.BACKLOG_API_KEY;
167
+ const spaceKey =
168
+ process.env.BACKLOG_SPACE_KEY ||
169
+ creds.BACKLOG_SPACE_KEY ||
170
+ process.env.BACKLOG_DOMAIN ||
171
+ creds.BACKLOG_DOMAIN;
172
+
173
+ if (!apiKey || !spaceKey) {
174
+ console.log(chalk.yellow("⚠ Backlog credentials not set."));
175
+ console.log(chalk.gray("Run: aiflow init --adapter backlog"));
176
+ console.log(chalk.gray("Or set environment variables:"));
177
+ console.log(chalk.gray(" BACKLOG_API_KEY=your-api-key"));
178
+ console.log(chalk.gray(" BACKLOG_SPACE_KEY=your-space (e.g. mycompany)"));
179
+ console.log(chalk.gray("\nFalling back to manual entry...\n"));
180
+ return await manualContext(issueKey);
181
+ }
182
+
183
+ // spaceKey can be full domain (mycompany.backlog.com) or just the space (mycompany)
184
+ const domain = spaceKey.includes(".") ? spaceKey : `${spaceKey}.backlog.com`;
185
+
186
+ console.log(chalk.blue(`Fetching context from Backlog: ${issueKey}...`));
187
+
188
+ try {
189
+ // Default: description only. Comments loaded only when explicitly requested.
190
+ const loadComments =
191
+ options.coms ||
192
+ options.withComs ||
193
+ options.withComments ||
194
+ options["with-comments"] ||
195
+ options.with_comments ||
196
+ options.cid != null ||
197
+ options.commentId != null ||
198
+ options["comment-id"] != null ||
199
+ options.clast != null ||
200
+ options.commentsLast != null ||
201
+ options.cfrom != null ||
202
+ options.commentsFrom != null ||
203
+ options.cto != null ||
204
+ options.commentsTo != null ||
205
+ options["comments-to"] != null;
206
+
207
+ if (loadComments) {
208
+ console.log(chalk.gray(" ℹ Comments requested..."));
168
209
  }
169
-
170
- // spaceKey can be full domain (mycompany.backlog.com) or just the space (mycompany)
171
- const domain = spaceKey.includes('.') ? spaceKey : `${spaceKey}.backlog.com`;
172
-
173
- console.log(chalk.blue(`Fetching context from Backlog: ${issueKey}...`));
174
-
175
- try {
176
- // Default: description only. Comments loaded only when explicitly requested.
177
- const loadComments = options.withComments || options.commentsLast != null || options.commentsFrom != null;
178
- const [issue, rawComments] = await Promise.all([
179
- fetchBacklogIssue(domain, apiKey, issueKey),
180
- loadComments
181
- ? fetchBacklogComments(domain, apiKey, issueKey)
182
- : Promise.resolve([]),
183
- ]);
184
-
185
- const comments = filterComments(rawComments, options);
186
- const context = buildContextFromBacklog(issue, comments, issueKey, domain);
187
- context.mode = options.full ? 'full' : 'fast';
188
- await saveContext(context, options.save);
189
- printContextSummary(context);
190
- await suggestNextStep();
191
- } catch (err) {
192
- console.log(chalk.yellow(`⚠ Could not fetch from Backlog: ${err.message}`));
193
- console.log(chalk.gray('Falling back to manual entry...\n'));
194
- await manualContext(issueKey);
210
+ const [issue, rawComments] = await Promise.all([
211
+ fetchBacklogIssue(domain, apiKey, issueKey),
212
+ loadComments
213
+ ? fetchBacklogComments(domain, apiKey, issueKey)
214
+ : Promise.resolve([]),
215
+ ]);
216
+
217
+ const comments = filterComments(rawComments, options);
218
+ if (loadComments) {
219
+ console.log(chalk.gray(` ℹ Comments: ${rawComments.length} fetched, ${comments.length} kept after filtering.`));
195
220
  }
221
+ const context = buildContextFromBacklog(issue, comments, issueKey, domain);
222
+ context.mode = options.full ? "full" : "fast";
223
+ await saveContext(context, options.save);
224
+ printContextSummary(context);
225
+ await suggestNextStep();
226
+ } catch (err) {
227
+ console.log(chalk.yellow(`⚠ Could not fetch from Backlog: ${err.message}`));
228
+ console.log(chalk.gray("Falling back to manual entry...\n"));
229
+ await manualContext(issueKey);
230
+ }
196
231
  }
197
232
 
198
233
  /**
@@ -203,120 +238,160 @@ async function loadFromBacklog(issueKey, options = {}) {
203
238
  * (default) → all comments
204
239
  */
205
240
  function filterComments(comments, options = {}) {
206
- if (!comments || !comments.length) return [];
207
-
208
- if (options.commentsLast != null) {
209
- const n = Math.max(1, options.commentsLast);
210
- return comments.slice(-n);
211
- }
212
-
213
- if (options.commentsFrom != null) {
214
- const from = Math.max(0, options.commentsFrom);
215
- return comments.slice(from);
216
- }
217
-
218
- return comments;
241
+ if (!comments || !comments.length) return [];
242
+
243
+ let result = [...comments];
244
+
245
+ // 1. Filter by specific ID if provided
246
+ const targetId = options.cid || options.commentId || options["comment-id"];
247
+ if (targetId) {
248
+ result = result.filter((c) => String(c.id) === String(targetId));
249
+ }
250
+
251
+ // 2. Filter by starting ID (from)
252
+ const fromId = options.cfrom || options.commentsFrom || options["comments-from"];
253
+ if (fromId) {
254
+ result = result.filter((c) => Number(c.id) >= Number(fromId));
255
+ }
256
+
257
+ // 3. Filter by ending ID (to)
258
+ const toId = options.cto || options.commentsTo || options["comments-to"];
259
+ if (toId) {
260
+ result = result.filter((c) => Number(c.id) <= Number(toId));
261
+ }
262
+
263
+ // 4. Filter by last N comments (if not using specific ID/range filters)
264
+ const lastN = options.clast || options.commentsLast;
265
+ if (lastN != null && !targetId && !fromId && !toId) {
266
+ const n = Math.max(1, lastN);
267
+ return result.slice(-n);
268
+ }
269
+
270
+ return result;
219
271
  }
220
272
 
221
273
  /**
222
274
  * Generic Backlog GET helper
223
275
  */
224
276
  function backlogGet(url) {
225
- return new Promise((resolve, reject) => {
226
- https.get(url, (res) => {
227
- let data = '';
228
- res.on('data', chunk => data += chunk);
229
- res.on('end', () => {
230
- if (res.statusCode !== 200) {
231
- reject(new Error(`HTTP ${res.statusCode}: ${data}`));
232
- return;
233
- }
234
- try {
235
- resolve(JSON.parse(data));
236
- } catch (e) {
237
- reject(new Error('Invalid JSON response from Backlog'));
238
- }
239
- });
240
- }).on('error', reject);
241
- });
277
+ return new Promise((resolve, reject) => {
278
+ https
279
+ .get(url, (res) => {
280
+ let data = "";
281
+ res.on("data", (chunk) => (data += chunk));
282
+ res.on("end", () => {
283
+ if (res.statusCode !== 200) {
284
+ reject(new Error(`HTTP ${res.statusCode}: ${data}`));
285
+ return;
286
+ }
287
+ try {
288
+ resolve(JSON.parse(data));
289
+ } catch (e) {
290
+ reject(new Error("Invalid JSON response from Backlog"));
291
+ }
292
+ });
293
+ })
294
+ .on("error", reject);
295
+ });
242
296
  }
243
297
 
244
298
  /**
245
299
  * Fetch issue detail from Backlog REST API
246
300
  */
247
301
  function fetchBacklogIssue(domain, apiKey, issueKey) {
248
- return backlogGet(`https://${domain}/api/v2/issues/${issueKey}?apiKey=${apiKey}`);
302
+ return backlogGet(
303
+ `https://${domain}/api/v2/issues/${issueKey}?apiKey=${apiKey}`,
304
+ );
249
305
  }
250
306
 
251
307
  /**
252
- * Fetch ALL comments of an issue from Backlog REST API (paginated, max 100/page).
308
+ * Fetch ALL comments of an issue from Backlog REST API.
309
+ * Uses minId for pagination because 'offset' is not supported for comments.
253
310
  */
254
311
  async function fetchBacklogComments(domain, apiKey, issueKey) {
255
- const all = [];
256
- let offset = 0;
257
- try {
258
- while (true) {
259
- const batch = await backlogGet(
260
- `https://${domain}/api/v2/issues/${issueKey}/comments?apiKey=${apiKey}&count=100&offset=${offset}&order=asc`
261
- );
262
- all.push(...batch);
263
- if (batch.length < 100) break;
264
- offset += 100;
265
- }
266
- } catch (_) {
267
- // comments are optional — don't fail the whole fetch
312
+ const all = [];
313
+ let minId = null;
314
+ try {
315
+ while (true) {
316
+ let url = `https://${domain}/api/v2/issues/${issueKey}/comments?apiKey=${apiKey}&count=100&order=asc`;
317
+ if (minId) {
318
+ url += `&minId=${minId}`;
319
+ }
320
+
321
+ const batch = await backlogGet(url);
322
+ if (!batch || batch.length === 0) break;
323
+
324
+ all.push(...batch);
325
+ if (batch.length < 100) break;
326
+
327
+ // Use the last ID as minId for the next batch
328
+ minId = batch[batch.length - 1].id;
268
329
  }
269
- return all;
330
+ } catch (err) {
331
+ console.log(
332
+ chalk.yellow(` ⚠ Warning: Could not fetch comments: ${err.message}`),
333
+ );
334
+ }
335
+ return all;
270
336
  }
271
337
 
272
338
  /**
273
339
  * Transform Backlog API response + comments to internal context format
274
340
  */
275
341
  function buildContextFromBacklog(issue, comments, issueKey, domain) {
276
- const type = detectTaskType(issue);
277
-
278
- // Format comments into readable text for AI context
279
- const formattedComments = (comments || [])
280
- .filter(c => c.content && c.content.trim())
281
- .map(c => {
282
- const author = c.createdUser?.name || 'Unknown';
283
- const date = c.created ? new Date(c.created).toLocaleDateString('vi-VN') : '';
284
- return `[${author} - ${date}]\n${c.content.trim()}`;
285
- });
286
-
287
- const allText = [issue.description || '', ...formattedComments.map(c => c)].join('\n');
288
-
289
- return {
290
- taskId: issueKey,
291
- taskType: type,
292
- title: issue.summary || '',
293
- description: issue.description || '',
294
- status: issue.status?.name || 'Unknown',
295
- priority: issue.priority?.name || 'Unknown',
296
- assignee: issue.assignee?.name || 'Unassigned',
297
-
298
- // Comments from all participants
299
- comments: formattedComments,
300
- commentCount: formattedComments.length,
301
-
302
- // Parse checklist-style acceptance criteria from description
303
- acceptanceCriteria: extractCriteria(issue.description || ''),
304
-
305
- // Related files mentioned anywhere in ticket
306
- context: {
307
- files: extractFiles(allText),
308
- mentionedServices: [],
309
- relatedTickets: extractRelatedTickets(allText),
310
- },
311
-
312
- metadata: {
313
- created: issue.created,
314
- updated: issue.updated,
315
- loadedFrom: 'backlog',
316
- adapter: 'backlog',
317
- sourceUrl: `https://${domain}/view/${issueKey}`,
318
- }
319
- };
342
+ const type = detectTaskType(issue);
343
+
344
+ // Format comments into readable text for AI context
345
+ const formattedComments = (comments || [])
346
+ .map((c) => {
347
+ // Backlog comments can have text in 'content' or be just changelogs
348
+ const text = c.content || "";
349
+ if (!text.trim()) return null;
350
+
351
+ const author = c.createdUser?.name || "Unknown";
352
+ const date = c.created
353
+ ? new Date(c.created).toLocaleDateString("vi-VN")
354
+ : "";
355
+ return `[${author} - ${date}]\n${text.trim()}`;
356
+ })
357
+ .filter(Boolean);
358
+
359
+ const allText = [
360
+ issue.description || "",
361
+ ...formattedComments.map((c) => c),
362
+ ].join("\n");
363
+
364
+ return {
365
+ taskId: issueKey,
366
+ taskType: type,
367
+ title: issue.summary || "",
368
+ description: issue.description || "",
369
+ status: issue.status?.name || "Unknown",
370
+ priority: issue.priority?.name || "Unknown",
371
+ assignee: issue.assignee?.name || "Unassigned",
372
+
373
+ // Comments from all participants
374
+ comments: formattedComments,
375
+ commentCount: formattedComments.length,
376
+
377
+ // Parse checklist-style acceptance criteria from description
378
+ acceptanceCriteria: extractCriteria(issue.description || ""),
379
+
380
+ // Related files mentioned anywhere in ticket
381
+ context: {
382
+ files: extractFiles(allText),
383
+ mentionedServices: [],
384
+ relatedTickets: extractRelatedTickets(allText),
385
+ },
386
+
387
+ metadata: {
388
+ created: issue.created,
389
+ updated: issue.updated,
390
+ loadedFrom: "backlog",
391
+ adapter: "backlog",
392
+ sourceUrl: `https://${domain}/view/${issueKey}`,
393
+ },
394
+ };
320
395
  }
321
396
 
322
397
  // ──────────────────────────────────────────────────────────────
@@ -324,167 +399,282 @@ function buildContextFromBacklog(issue, comments, issueKey, domain) {
324
399
  // ──────────────────────────────────────────────────────────────
325
400
 
326
401
  async function loadFromJira(issueKey, options = {}) {
327
- const creds = await loadCredentials();
328
- const apiToken = process.env.JIRA_API_TOKEN || creds.JIRA_API_TOKEN;
329
- const email = process.env.JIRA_EMAIL || creds.JIRA_EMAIL;
330
- const domain = process.env.JIRA_DOMAIN || creds.JIRA_DOMAIN;
331
-
332
- if (!apiToken || !email || !domain) {
333
- console.log(chalk.yellow('⚠ Jira credentials not set.'));
334
- console.log(chalk.gray('Run: aiflow init --adapter jira'));
335
- console.log(chalk.gray('Or set environment variables: JIRA_API_TOKEN, JIRA_EMAIL, JIRA_DOMAIN'));
336
- console.log(chalk.gray('\nFalling back to manual entry...\n'));
337
- return await manualContext(issueKey);
402
+ const creds = await loadCredentials();
403
+ const apiToken = process.env.JIRA_API_TOKEN || creds.JIRA_API_TOKEN;
404
+ const email = process.env.JIRA_EMAIL || creds.JIRA_EMAIL;
405
+ const domain = process.env.JIRA_DOMAIN || creds.JIRA_DOMAIN;
406
+
407
+ if (!apiToken || !email || !domain) {
408
+ console.log(chalk.yellow("⚠ Jira credentials not set."));
409
+ console.log(chalk.gray("Run: aiflow init --adapter jira"));
410
+ console.log(
411
+ chalk.gray(
412
+ "Or set environment variables: JIRA_API_TOKEN, JIRA_EMAIL, JIRA_DOMAIN",
413
+ ),
414
+ );
415
+ console.log(chalk.gray("\nFalling back to manual entry...\n"));
416
+ return await manualContext(issueKey);
417
+ }
418
+
419
+ console.log(chalk.blue(`Fetching context from Jira: ${issueKey}...`));
420
+
421
+ try {
422
+ const loadComments =
423
+ options.coms ||
424
+ options.withComs ||
425
+ options.withComments ||
426
+ options["with-comments"] ||
427
+ options.with_comments ||
428
+ options.cid != null ||
429
+ options.commentId != null ||
430
+ options["comment-id"] != null ||
431
+ options.clast != null ||
432
+ options.commentsLast != null ||
433
+ options.cfrom != null ||
434
+ options.commentsFrom != null ||
435
+ options.cto != null ||
436
+ options.commentsTo != null ||
437
+ options["comments-to"] != null;
438
+
439
+ if (loadComments) {
440
+ console.log(chalk.gray(" ℹ Comments requested..."));
338
441
  }
339
-
340
- console.log(chalk.blue(`Fetching context from Jira: ${issueKey}...`));
341
-
342
- try {
343
- const issue = await fetchJiraIssue(domain, email, apiToken, issueKey);
344
- const context = buildContextFromJira(issue, issueKey, domain);
345
- context.mode = options.full ? 'full' : 'fast';
346
- await saveContext(context, options.save);
347
- printContextSummary(context);
348
- await suggestNextStep();
349
- } catch (err) {
350
- console.log(chalk.yellow(`⚠ Could not fetch from Jira: ${err.message}`));
351
- console.log(chalk.gray('Falling back to manual entry...\n'));
352
- await manualContext(issueKey);
442
+ const [issue, rawComments] = await Promise.all([
443
+ fetchJiraIssue(domain, email, apiToken, issueKey),
444
+ loadComments
445
+ ? fetchJiraComments(domain, email, apiToken, issueKey)
446
+ : Promise.resolve([]),
447
+ ]);
448
+
449
+ const comments = filterComments(rawComments, options);
450
+ if (loadComments) {
451
+ console.log(chalk.gray(` ℹ Comments: ${rawComments.length} fetched, ${comments.length} kept after filtering.`));
353
452
  }
453
+ const context = buildContextFromJira(issue, comments, issueKey, domain);
454
+ context.mode = options.full ? "full" : "fast";
455
+ await saveContext(context, options.save);
456
+ printContextSummary(context);
457
+ await suggestNextStep();
458
+ } catch (err) {
459
+ console.log(chalk.yellow(`⚠ Could not fetch from Jira: ${err.message}`));
460
+ console.log(chalk.gray("Falling back to manual entry...\n"));
461
+ await manualContext(issueKey);
462
+ }
354
463
  }
355
464
 
356
465
  function fetchJiraIssue(domain, email, apiToken, issueKey) {
357
- return new Promise((resolve, reject) => {
358
- const auth = Buffer.from(`${email}:${apiToken}`).toString('base64');
359
- const url = `https://${domain}.atlassian.net/rest/api/3/issue/${issueKey}`;
360
- const options = {
361
- headers: {
362
- 'Authorization': `Basic ${auth}`,
363
- 'Accept': 'application/json'
364
- }
365
- };
366
-
367
- https.get(url, options, (res) => {
368
- let data = '';
369
- res.on('data', chunk => data += chunk);
370
- res.on('end', () => {
371
- if (res.statusCode !== 200) {
372
- reject(new Error(`HTTP ${res.statusCode}: ${data}`));
373
- return;
374
- }
375
- try {
376
- resolve(JSON.parse(data));
377
- } catch (e) {
378
- reject(new Error('Invalid JSON response from Jira'));
379
- }
380
- });
381
- }).on('error', reject);
382
- });
466
+ return new Promise((resolve, reject) => {
467
+ const auth = Buffer.from(`${email}:${apiToken}`).toString("base64");
468
+ const url = `https://${domain}.atlassian.net/rest/api/3/issue/${issueKey}`;
469
+ const options = {
470
+ headers: {
471
+ Authorization: `Basic ${auth}`,
472
+ Accept: "application/json",
473
+ },
474
+ };
475
+
476
+ https
477
+ .get(url, options, (res) => {
478
+ let data = "";
479
+ res.on("data", (chunk) => (data += chunk));
480
+ res.on("end", () => {
481
+ if (res.statusCode !== 200) {
482
+ reject(new Error(`HTTP ${res.statusCode}: ${data}`));
483
+ return;
484
+ }
485
+ try {
486
+ resolve(JSON.parse(data));
487
+ } catch (e) {
488
+ reject(new Error("Invalid JSON response from Jira"));
489
+ }
490
+ });
491
+ })
492
+ .on("error", reject);
493
+ });
383
494
  }
384
495
 
385
- function buildContextFromJira(issue, issueKey, domain) {
386
- const fields = issue.fields || {};
387
- const type = detectTaskTypeFromString(fields.issuetype?.name || '');
388
-
389
- return {
390
- taskId: issueKey,
391
- taskType: type,
392
- title: fields.summary || '',
393
- description: fields.description?.content?.map(block =>
394
- block.content?.map(c => c.text).join('') || ''
395
- ).join('\n') || '',
396
- status: fields.status?.name || 'Unknown',
397
- priority: fields.priority?.name || 'Unknown',
398
- assignee: fields.assignee?.displayName || 'Unassigned',
399
- acceptanceCriteria: extractCriteria(fields.description?.toString() || ''),
400
- context: {
401
- files: [],
402
- relatedTickets: []
403
- },
404
- metadata: {
405
- created: fields.created,
406
- updated: fields.updated,
407
- loadedFrom: 'jira',
408
- adapter: 'jira',
409
- sourceUrl: `https://${domain}.atlassian.net/browse/${issueKey}`
410
- }
496
+ /**
497
+ * Fetch ALL comments of an issue from Jira REST API.
498
+ */
499
+ async function fetchJiraComments(domain, email, apiToken, issueKey) {
500
+ return new Promise((resolve, reject) => {
501
+ const auth = Buffer.from(`${email}:${apiToken}`).toString("base64");
502
+ const url = `https://${domain}.atlassian.net/rest/api/3/issue/${issueKey}/comment`;
503
+ const options = {
504
+ headers: {
505
+ Authorization: `Basic ${auth}`,
506
+ Accept: "application/json",
507
+ },
411
508
  };
509
+
510
+ https
511
+ .get(url, options, (res) => {
512
+ let data = "";
513
+ res.on("data", (chunk) => (data += chunk));
514
+ res.on("end", () => {
515
+ if (res.statusCode !== 200) {
516
+ // Comments are optional, but log if we failed
517
+ console.log(
518
+ chalk.yellow(
519
+ ` ⚠ Warning: Jira comments API returned ${res.statusCode}`,
520
+ ),
521
+ );
522
+ resolve([]);
523
+ return;
524
+ }
525
+ try {
526
+ const json = JSON.parse(data);
527
+ resolve(json.comments || []);
528
+ } catch (e) {
529
+ resolve([]);
530
+ }
531
+ });
532
+ })
533
+ .on("error", () => resolve([]));
534
+ });
535
+ }
536
+
537
+ function buildContextFromJira(issue, comments, issueKey, domain) {
538
+ const fields = issue.fields || {};
539
+ const type = detectTaskTypeFromString(fields.issuetype?.name || "");
540
+
541
+ // Format comments for Jira (v3 uses ADF format for body)
542
+ const formattedComments = (comments || [])
543
+ .map((c) => {
544
+ const author = c.author?.displayName || "Unknown";
545
+ const date = c.created
546
+ ? new Date(c.created).toLocaleDateString("vi-VN")
547
+ : "";
548
+ const body =
549
+ c.body?.content
550
+ ?.map(
551
+ (block) => block.content?.map((inner) => inner.text).join("") || "",
552
+ )
553
+ .join("\n") || "";
554
+ return body.trim() ? `[${author} - ${date}]\n${body.trim()}` : null;
555
+ })
556
+ .filter(Boolean);
557
+
558
+ return {
559
+ taskId: issueKey,
560
+ taskType: type,
561
+ title: fields.summary || "",
562
+ description:
563
+ fields.description?.content
564
+ ?.map((block) => block.content?.map((c) => c.text).join("") || "")
565
+ .join("\n") || "",
566
+ status: fields.status?.name || "Unknown",
567
+ priority: fields.priority?.name || "Unknown",
568
+ assignee: fields.assignee?.displayName || "Unassigned",
569
+
570
+ // Comments
571
+ comments: formattedComments,
572
+ commentCount: formattedComments.length,
573
+
574
+ acceptanceCriteria: extractCriteria(fields.description?.toString() || ""),
575
+ context: {
576
+ files: [],
577
+ relatedTickets: [],
578
+ },
579
+ metadata: {
580
+ created: fields.created,
581
+ updated: fields.updated,
582
+ loadedFrom: "jira",
583
+ adapter: "jira",
584
+ sourceUrl: `https://${domain}.atlassian.net/browse/${issueKey}`,
585
+ },
586
+ };
412
587
  }
413
588
 
414
589
  // ──────────────────────────────────────────────────────────────
415
590
  // Manual context entry
416
591
  // ──────────────────────────────────────────────────────────────
417
592
 
418
- async function manualContext(prefillId = '') {
419
- // Load existing context for pre-fill (Edit mode)
420
- let existing = {};
421
- const currentPath = path.join(CONTEXT_DIR, 'current.json');
422
- if (await fs.pathExists(currentPath)) {
423
- existing = await fs.readJson(currentPath).catch(() => ({}));
424
- }
425
-
426
- const isEdit = !!(existing.taskId);
427
- if (isEdit) {
428
- console.log(chalk.cyan('\nEdit Context\n'));
429
- console.log(chalk.gray('Press Enter to keep existing value shown in [brackets]\n'));
430
- } else {
431
- console.log(chalk.cyan('\nManual Context Entry\n'));
432
- }
433
-
434
- // Ticket ID
435
- const defaultId = prefillId || existing.taskId || '';
436
- const idHint = defaultId ? chalk.gray(` [${defaultId}]`) : '';
437
- const idInput = await input({ message: `Ticket ID (e.g. PROJ-33)${idHint}:`, default: '' });
438
- const taskId = idInput.trim() || defaultId;
439
-
440
- // Title
441
- const defaultTitle = existing.title || '';
442
- const titlePreview = defaultTitle.length > 50 ? defaultTitle.substring(0, 50) + '…' : defaultTitle;
443
- const titleHint = defaultTitle ? chalk.gray(` [${titlePreview}]`) : '';
444
- const titleInput = await input({ message: `Title${titleHint}:`, default: '' });
445
- const title = titleInput.trim() || defaultTitle;
446
-
447
- // Description
448
- const defaultDesc = existing.description || '';
449
- const descPreview = defaultDesc.length > 60 ? defaultDesc.substring(0, 60) + '' : defaultDesc;
450
- const descHint = defaultDesc ? chalk.gray(` [${descPreview}]`) : '';
451
- const descInput = await input({ message: `Description (brief)${descHint}:`, default: '' });
452
- const description = descInput.trim() || defaultDesc;
453
-
454
- // Task type — pre-select existing value if available
455
- const taskType = await select({
456
- message: 'Task type:',
457
- choices: [
458
- { name: '🐛 Bug Fix', value: 'bug-fix' },
459
- { name: '✨ Feature', value: 'feature' },
460
- { name: '🔍 Investigation', value: 'investigation' },
461
- { name: '♻️ Refactor', value: 'refactor' },
462
- { name: '📊 Impact Analysis', value: 'impact-analysis' },
463
- { name: '📖 Documentation', value: 'documentation' }
464
- ],
465
- default: existing.taskType || undefined
466
- });
467
-
468
- const context = {
469
- taskId,
470
- taskType,
471
- title,
472
- description,
473
- status: existing.status || 'In Progress',
474
- mode: existing.mode || 'auto',
475
- acceptanceCriteria: existing.acceptanceCriteria || [],
476
- context: existing.context || { files: [], relatedTickets: [] },
477
- metadata: {
478
- created: existing.metadata?.created || new Date().toISOString(),
479
- updated: new Date().toISOString(),
480
- loadedFrom: 'manual',
481
- adapter: 'manual'
482
- }
483
- };
484
-
485
- await saveContext(context);
486
- printContextSummary(context);
487
- await suggestNextStep();
593
+ async function manualContext(prefillId = "") {
594
+ // Load existing context for pre-fill (Edit mode)
595
+ let existing = {};
596
+ const currentPath = path.join(CONTEXT_DIR, "current.json");
597
+ if (await fs.pathExists(currentPath)) {
598
+ existing = await fs.readJson(currentPath).catch(() => ({}));
599
+ }
600
+
601
+ const isEdit = !!existing.taskId;
602
+ if (isEdit) {
603
+ console.log(chalk.cyan("\nEdit Context\n"));
604
+ console.log(
605
+ chalk.gray("Press Enter to keep existing value shown in [brackets]\n"),
606
+ );
607
+ } else {
608
+ console.log(chalk.cyan("\nManual Context Entry\n"));
609
+ }
610
+
611
+ // Ticket ID
612
+ const defaultId = prefillId || existing.taskId || "";
613
+ const idHint = defaultId ? chalk.gray(` [${defaultId}]`) : "";
614
+ const idInput = await input({
615
+ message: `Ticket ID (e.g. PROJ-33)${idHint}:`,
616
+ default: "",
617
+ });
618
+ const taskId = idInput.trim() || defaultId;
619
+
620
+ // Title
621
+ const defaultTitle = existing.title || "";
622
+ const titlePreview =
623
+ defaultTitle.length > 50
624
+ ? defaultTitle.substring(0, 50) + ""
625
+ : defaultTitle;
626
+ const titleHint = defaultTitle ? chalk.gray(` [${titlePreview}]`) : "";
627
+ const titleInput = await input({
628
+ message: `Title${titleHint}:`,
629
+ default: "",
630
+ });
631
+ const title = titleInput.trim() || defaultTitle;
632
+
633
+ // Description
634
+ const defaultDesc = existing.description || "";
635
+ const descPreview =
636
+ defaultDesc.length > 60 ? defaultDesc.substring(0, 60) + "…" : defaultDesc;
637
+ const descHint = defaultDesc ? chalk.gray(` [${descPreview}]`) : "";
638
+ const descInput = await input({
639
+ message: `Description (brief)${descHint}:`,
640
+ default: "",
641
+ });
642
+ const description = descInput.trim() || defaultDesc;
643
+
644
+ // Task type — pre-select existing value if available
645
+ const taskType = await select({
646
+ message: "Task type:",
647
+ choices: [
648
+ { name: "🐛 Bug Fix", value: "bug-fix" },
649
+ { name: "✨ Feature", value: "feature" },
650
+ { name: "🔍 Investigation", value: "investigation" },
651
+ { name: "♻️ Refactor", value: "refactor" },
652
+ { name: "📊 Impact Analysis", value: "impact-analysis" },
653
+ { name: "📖 Documentation", value: "documentation" },
654
+ ],
655
+ default: existing.taskType || undefined,
656
+ });
657
+
658
+ const context = {
659
+ taskId,
660
+ taskType,
661
+ title,
662
+ description,
663
+ status: existing.status || "In Progress",
664
+ mode: existing.mode || "auto",
665
+ acceptanceCriteria: existing.acceptanceCriteria || [],
666
+ context: existing.context || { files: [], relatedTickets: [] },
667
+ metadata: {
668
+ created: existing.metadata?.created || new Date().toISOString(),
669
+ updated: new Date().toISOString(),
670
+ loadedFrom: "manual",
671
+ adapter: "manual",
672
+ },
673
+ };
674
+
675
+ await saveContext(context);
676
+ printContextSummary(context);
677
+ await suggestNextStep();
488
678
  }
489
679
 
490
680
  // ──────────────────────────────────────────────────────────────
@@ -492,94 +682,104 @@ async function manualContext(prefillId = '') {
492
682
  // ──────────────────────────────────────────────────────────────
493
683
 
494
684
  async function loadFromFile(filePath, options = {}) {
495
- if (!(await fs.pathExists(filePath))) {
496
- console.log(chalk.red(`File not found: ${filePath}`));
497
- return;
498
- }
499
-
500
- const raw = await fs.readFile(filePath, 'utf-8');
501
- let context;
502
-
503
- // Try JSON first; fall back to plain-text auto-detection
504
- try {
505
- context = JSON.parse(raw);
506
- } catch (_) {
507
- console.log(chalk.gray(' File is not JSON — auto-detecting task info from content...'));
508
- context = buildContextFromPlainText(raw, filePath);
509
- }
510
-
511
- context.mode = options.full ? 'full' : 'fast';
512
-
513
- // Prompt for task type
514
- context.taskType = await select({
515
- message: 'Task type:',
516
- choices: [
517
- { name: '🐛 Bug Fix', value: 'bug-fix' },
518
- { name: '✨ Feature', value: 'feature' },
519
- { name: '🔍 Investigation', value: 'investigation' },
520
- { name: '♻️ Refactor', value: 'refactor' },
521
- { name: '📊 Impact Analysis', value: 'impact-analysis' },
522
- { name: '📖 Documentation', value: 'documentation' }
523
- ],
524
- default: context.taskType || 'feature'
525
- });
526
-
527
- await saveContext(context, options.save);
528
- printContextSummary(context);
529
- await suggestNextStep();
685
+ if (!(await fs.pathExists(filePath))) {
686
+ console.log(chalk.red(`File not found: ${filePath}`));
687
+ return;
688
+ }
689
+
690
+ const raw = await fs.readFile(filePath, "utf-8");
691
+ let context;
692
+
693
+ // Try JSON first; fall back to plain-text auto-detection
694
+ try {
695
+ context = JSON.parse(raw);
696
+ } catch (_) {
697
+ console.log(
698
+ chalk.gray(
699
+ " File is not JSON — auto-detecting task info from content...",
700
+ ),
701
+ );
702
+ context = buildContextFromPlainText(raw, filePath);
703
+ }
704
+
705
+ context.mode = options.full ? "full" : "fast";
706
+
707
+ // Prompt for task type
708
+ context.taskType = await select({
709
+ message: "Task type:",
710
+ choices: [
711
+ { name: "🐛 Bug Fix", value: "bug-fix" },
712
+ { name: "✨ Feature", value: "feature" },
713
+ { name: "🔍 Investigation", value: "investigation" },
714
+ { name: "♻️ Refactor", value: "refactor" },
715
+ { name: "📊 Impact Analysis", value: "impact-analysis" },
716
+ { name: "📖 Documentation", value: "documentation" },
717
+ ],
718
+ default: context.taskType || "feature",
719
+ });
720
+
721
+ await saveContext(context, options.save);
722
+ printContextSummary(context);
723
+ await suggestNextStep();
530
724
  }
531
725
 
532
726
  /**
533
727
  * Build a minimal context object from arbitrary plain-text file content.
534
728
  * Tries to detect ticket ID (e.g. PROJ-33, APP-123) and a title from the first non-blank line.
535
729
  */
536
- function buildContextFromPlainText(text, filePath = '') {
537
- // Detect ticket ID: first PROJ-123 style token anywhere in the text
538
- const idMatch = text.match(/\b([A-Z][A-Z0-9_]+-\d+)\b/);
539
-
540
- let taskId;
541
- if (idMatch) {
542
- taskId = idMatch[1];
543
- } else {
544
- const fileNameOnly = filePath ? path.basename(filePath, path.extname(filePath)) : '';
545
- const cleanWords = fileNameOnly.replace(/[^a-zA-Z0-9]/g, ' ')
546
- .split(/\s+/)
547
- .filter(Boolean)
548
- .slice(0, 5)
549
- .join('-')
550
- .toUpperCase();
551
- const prefix = cleanWords ? `TASK-${cleanWords}-` : 'TASK-';
552
- taskId = `${prefix}${Date.now()}`;
553
- }
554
-
555
- // Title: use full filename if available, otherwise first non-blank line
556
- const fullFileName = filePath ? path.basename(filePath) : '';
557
- const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
558
- const titleLine = fullFileName || lines.find(l => l.length > 3) || taskId;
559
- const title = titleLine.substring(0, 120);
560
-
561
- // Task type detection from text
562
- const taskType = detectTaskTypeFromString(text);
563
-
564
- return {
565
- taskId,
566
- taskType,
567
- title,
568
- description: text.substring(0, 3000),
569
- status: 'In Progress',
570
- assignee: 'Unassigned',
571
- acceptanceCriteria: extractCriteria(text),
572
- context: {
573
- files: extractFiles(text),
574
- relatedTickets: extractRelatedTickets(text),
575
- },
576
- metadata: {
577
- created: new Date().toISOString(),
578
- updated: new Date().toISOString(),
579
- loadedFrom: 'file',
580
- adapter: 'file',
581
- },
582
- };
730
+ function buildContextFromPlainText(text, filePath = "") {
731
+ // Detect ticket ID: first PROJ-123 style token anywhere in the text
732
+ const idMatch = text.match(/\b([A-Z][A-Z0-9_]+-\d+)\b/);
733
+
734
+ let taskId;
735
+ if (idMatch) {
736
+ taskId = idMatch[1];
737
+ } else {
738
+ const fileNameOnly = filePath
739
+ ? path.basename(filePath, path.extname(filePath))
740
+ : "";
741
+ const cleanWords = fileNameOnly
742
+ .replace(/[^a-zA-Z0-9]/g, " ")
743
+ .split(/\s+/)
744
+ .filter(Boolean)
745
+ .slice(0, 5)
746
+ .join("-")
747
+ .toUpperCase();
748
+ const prefix = cleanWords ? `TASK-${cleanWords}-` : "TASK-";
749
+ taskId = `${prefix}${Date.now()}`;
750
+ }
751
+
752
+ // Title: use full filename if available, otherwise first non-blank line
753
+ const fullFileName = filePath ? path.basename(filePath) : "";
754
+ const lines = text
755
+ .split(/\r?\n/)
756
+ .map((l) => l.trim())
757
+ .filter(Boolean);
758
+ const titleLine = fullFileName || lines.find((l) => l.length > 3) || taskId;
759
+ const title = titleLine.substring(0, 120);
760
+
761
+ // Task type detection from text
762
+ const taskType = detectTaskTypeFromString(text);
763
+
764
+ return {
765
+ taskId,
766
+ taskType,
767
+ title,
768
+ description: text.substring(0, 3000),
769
+ status: "In Progress",
770
+ assignee: "Unassigned",
771
+ acceptanceCriteria: extractCriteria(text),
772
+ context: {
773
+ files: extractFiles(text),
774
+ relatedTickets: extractRelatedTickets(text),
775
+ },
776
+ metadata: {
777
+ created: new Date().toISOString(),
778
+ updated: new Date().toISOString(),
779
+ loadedFrom: "file",
780
+ adapter: "file",
781
+ },
782
+ };
583
783
  }
584
784
 
585
785
  // ──────────────────────────────────────────────────────────────
@@ -587,17 +787,25 @@ function buildContextFromPlainText(text, filePath = '') {
587
787
  // ──────────────────────────────────────────────────────────────
588
788
 
589
789
  async function loadCredentials() {
590
- const globalCredsFile = path.join(os.homedir(), '.aiflow', 'credentials.json');
591
- if (await fs.pathExists(globalCredsFile)) {
592
- const data = await fs.readJson(globalCredsFile).catch(() => ({}));
593
- if (data.mcp && Object.keys(data.mcp).length > 0) return data.mcp;
594
- }
595
- // Fallback: project-level credentials (legacy support)
596
- const projectCredsFile = path.join(PROJECT_DIR, '.aiflow', 'credentials.json');
597
- if (await fs.pathExists(projectCredsFile)) {
598
- return await fs.readJson(projectCredsFile).catch(() => ({}));
599
- }
600
- return {};
790
+ const globalCredsFile = path.join(
791
+ os.homedir(),
792
+ ".aiflow",
793
+ "credentials.json",
794
+ );
795
+ if (await fs.pathExists(globalCredsFile)) {
796
+ const data = await fs.readJson(globalCredsFile).catch(() => ({}));
797
+ if (data.mcp && Object.keys(data.mcp).length > 0) return data.mcp;
798
+ }
799
+ // Fallback: project-level credentials (legacy support)
800
+ const projectCredsFile = path.join(
801
+ PROJECT_DIR,
802
+ ".aiflow",
803
+ "credentials.json",
804
+ );
805
+ if (await fs.pathExists(projectCredsFile)) {
806
+ return await fs.readJson(projectCredsFile).catch(() => ({}));
807
+ }
808
+ return {};
601
809
  }
602
810
 
603
811
  // ──────────────────────────────────────────────────────────────
@@ -605,69 +813,85 @@ async function loadCredentials() {
605
813
  // ──────────────────────────────────────────────────────────────
606
814
 
607
815
  async function saveContext(context, saveName) {
608
- await fs.ensureDir(CONTEXT_DIR);
609
- await fs.ensureDir(path.join(CONTEXT_DIR, 'history'));
610
-
611
- // Auto-pause any currently active task that differs from this one
612
- const currentFile = path.join(CONTEXT_DIR, 'current.json');
613
- if (context.taskId && (await fs.pathExists(currentFile))) {
614
- const prev = await fs.readJson(currentFile).catch(() => null);
615
- if (prev && prev.taskId && prev.taskId !== context.taskId) {
616
- try {
617
- const { createOrActivateTaskState } = require('./task');
618
- // Save previous task state as pending before switching
619
- const prevTaskDir = path.join(AIFLOW_DIR, 'tasks', prev.taskId);
620
- const prevStatePath = path.join(prevTaskDir, 'task-state.json');
621
- await fs.ensureDir(prevTaskDir);
622
- await fs.writeJson(path.join(prevTaskDir, 'context.json'), prev, { spaces: 2 });
623
- const prevState = (await fs.pathExists(prevStatePath)) ? await fs.readJson(prevStatePath).catch(() => ({})) : {};
624
- const now = new Date().toISOString();
625
- await fs.writeJson(prevStatePath, {
626
- ...prevState,
627
- taskId: prev.taskId,
628
- title: prev.title || '',
629
- status: 'pending',
630
- updatedAt: now,
631
- pausedAt: now,
632
- currentGate: prevState.currentGate || 1,
633
- gateApprovals: prevState.gateApprovals || {},
634
- notes: prevState.notes || '',
635
- createdAt: prevState.createdAt || now,
636
- }, { spaces: 2 });
637
- console.log(chalk.gray(` Auto-paused previous task: ${prev.taskId}`));
638
- } catch (_) { /* task module not available */ }
639
- }
640
- }
641
-
642
- // Save as current
643
- await fs.writeJson(currentFile, context, { spaces: 2 });
644
-
645
- // Save to history
646
- const histFile = path.join(CONTEXT_DIR, 'history', `${context.taskId || 'manual'}.json`);
647
- await fs.writeJson(histFile, context, { spaces: 2 });
648
-
649
- // Save named snapshot if requested
650
- if (saveName) {
651
- const namedFile = path.join(CONTEXT_DIR, 'history', `${saveName}.json`);
652
- await fs.writeJson(namedFile, context, { spaces: 2 });
653
- console.log(chalk.gray(` Context also saved as: ${saveName}`));
816
+ await fs.ensureDir(CONTEXT_DIR);
817
+ await fs.ensureDir(path.join(CONTEXT_DIR, "history"));
818
+
819
+ // Auto-pause any currently active task that differs from this one
820
+ const currentFile = path.join(CONTEXT_DIR, "current.json");
821
+ if (context.taskId && (await fs.pathExists(currentFile))) {
822
+ const prev = await fs.readJson(currentFile).catch(() => null);
823
+ if (prev && prev.taskId && prev.taskId !== context.taskId) {
824
+ try {
825
+ const { createOrActivateTaskState } = require("./task");
826
+ // Save previous task state as pending before switching
827
+ const prevTaskDir = path.join(AIFLOW_DIR, "tasks", prev.taskId);
828
+ const prevStatePath = path.join(prevTaskDir, "task-state.json");
829
+ await fs.ensureDir(prevTaskDir);
830
+ await fs.writeJson(path.join(prevTaskDir, "context.json"), prev, {
831
+ spaces: 2,
832
+ });
833
+ const prevState = (await fs.pathExists(prevStatePath))
834
+ ? await fs.readJson(prevStatePath).catch(() => ({}))
835
+ : {};
836
+ const now = new Date().toISOString();
837
+ await fs.writeJson(
838
+ prevStatePath,
839
+ {
840
+ ...prevState,
841
+ taskId: prev.taskId,
842
+ title: prev.title || "",
843
+ status: "pending",
844
+ updatedAt: now,
845
+ pausedAt: now,
846
+ currentGate: prevState.currentGate || 1,
847
+ gateApprovals: prevState.gateApprovals || {},
848
+ notes: prevState.notes || "",
849
+ createdAt: prevState.createdAt || now,
850
+ },
851
+ { spaces: 2 },
852
+ );
853
+ console.log(chalk.gray(` Auto-paused previous task: ${prev.taskId}`));
854
+ } catch (_) {
855
+ /* task module not available */
856
+ }
654
857
  }
655
-
656
- // Create / activate task-state for the new task
657
- if (context.taskId) {
658
- try {
659
- const { createOrActivateTaskState } = require('./task');
660
- await createOrActivateTaskState(context);
661
- } catch (_) { /* task module not available */ }
858
+ }
859
+
860
+ // Save as current
861
+ await fs.writeJson(currentFile, context, { spaces: 2 });
862
+
863
+ // Save to history
864
+ const histFile = path.join(
865
+ CONTEXT_DIR,
866
+ "history",
867
+ `${context.taskId || "manual"}.json`,
868
+ );
869
+ await fs.writeJson(histFile, context, { spaces: 2 });
870
+
871
+ // Save named snapshot if requested
872
+ if (saveName) {
873
+ const namedFile = path.join(CONTEXT_DIR, "history", `${saveName}.json`);
874
+ await fs.writeJson(namedFile, context, { spaces: 2 });
875
+ console.log(chalk.gray(` Context also saved as: ${saveName}`));
876
+ }
877
+
878
+ // Create / activate task-state for the new task
879
+ if (context.taskId) {
880
+ try {
881
+ const { createOrActivateTaskState } = require("./task");
882
+ await createOrActivateTaskState(context);
883
+ } catch (_) {
884
+ /* task module not available */
662
885
  }
886
+ }
663
887
 
664
- // Update state
665
- if (await fs.pathExists(STATE_FILE)) {
666
- const state = await fs.readJson(STATE_FILE);
667
- state.current_context = context.taskId;
668
- state.current_context_type = context.taskType;
669
- await fs.writeJson(STATE_FILE, state);
670
- }
888
+ // Update state
889
+ if (await fs.pathExists(STATE_FILE)) {
890
+ const state = await fs.readJson(STATE_FILE);
891
+ state.current_context = context.taskId;
892
+ state.current_context_type = context.taskType;
893
+ await fs.writeJson(STATE_FILE, state);
894
+ }
671
895
  }
672
896
 
673
897
  // ──────────────────────────────────────────────────────────────
@@ -675,55 +899,66 @@ async function saveContext(context, saveName) {
675
899
  // ──────────────────────────────────────────────────────────────
676
900
 
677
901
  function detectTaskType(issue) {
678
- const name = issue.issueType?.name || issue.type?.name || '';
679
- const summary = issue.summary || '';
680
- return detectTaskTypeFromString(name || summary);
902
+ const name = issue.issueType?.name || issue.type?.name || "";
903
+ const summary = issue.summary || "";
904
+ return detectTaskTypeFromString(name || summary);
681
905
  }
682
906
 
683
907
  function detectTaskTypeFromString(text) {
684
- const lower = text.toLowerCase();
685
- if (['bug', 'defect', 'バグ'].some(k => lower.includes(k))) return 'bug-fix';
686
- if (['task', 'story', 'feature', 'タスク'].some(k => lower.includes(k))) return 'feature';
687
- if (['research', 'investigation', '調査'].some(k => lower.includes(k))) return 'investigation';
688
- return 'bug-fix'; // default for unknown
908
+ const lower = text.toLowerCase();
909
+ if (["bug", "defect", "バグ"].some((k) => lower.includes(k)))
910
+ return "bug-fix";
911
+ if (["task", "story", "feature", "タスク"].some((k) => lower.includes(k)))
912
+ return "feature";
913
+ if (["research", "investigation", "調査"].some((k) => lower.includes(k)))
914
+ return "investigation";
915
+ return "bug-fix"; // default for unknown
689
916
  }
690
917
 
691
918
  function extractCriteria(text) {
692
- const lines = text.split('\n');
693
- return lines
694
- .filter(l => l.trim().match(/^[-*•]|^\d+\./))
695
- .map(l => l.replace(/^[-*•\d.]+\s*/, '').trim())
696
- .filter(l => l.length > 0)
697
- .slice(0, 10);
919
+ const lines = text.split("\n");
920
+ return lines
921
+ .filter((l) => l.trim().match(/^[-*•]|^\d+\./))
922
+ .map((l) => l.replace(/^[-*•\d.]+\s*/, "").trim())
923
+ .filter((l) => l.length > 0)
924
+ .slice(0, 10);
698
925
  }
699
926
 
700
927
  function extractFiles(text) {
701
- const matches = text.match(/[\w/]+\.(php|js|ts|vue|jsx|tsx|css|html)/g) || [];
702
- return [...new Set(matches)];
928
+ const matches = text.match(/[\w/]+\.(php|js|ts|vue|jsx|tsx|css|html)/g) || [];
929
+ return [...new Set(matches)];
703
930
  }
704
931
 
705
932
  function extractRelatedTickets(text) {
706
- const matches = text.match(/[A-Z][A-Z0-9_]+-\d+/g) || [];
707
- return [...new Set(matches)];
933
+ const matches = text.match(/[A-Z][A-Z0-9_]+-\d+/g) || [];
934
+ return [...new Set(matches)];
708
935
  }
709
936
 
710
937
  function printContextSummary(context) {
711
- console.log(chalk.green('\n✓ Context loaded\n'));
712
- console.log(` ${chalk.white('Ticket:')} ${context.taskId}`);
713
- console.log(` ${chalk.white('Type:')} ${context.taskType}`);
714
- console.log(` ${chalk.white('Title:')} ${context.title.substring(0, 70)}`);
715
- console.log(` ${chalk.white('Status:')} ${context.status}`);
716
- console.log(` ${chalk.white('Assignee:')} ${context.assignee}`);
717
- if (context.description) {
718
- console.log(` ${chalk.white('Desc:')} ${context.description.substring(0, 80)}...`);
719
- }
720
- if (context.acceptanceCriteria?.length) {
721
- console.log(` ${chalk.white('Criteria:')} ${context.acceptanceCriteria.length} item(s)`);
722
- }
723
- if (context.commentCount > 0) {
724
- console.log(` ${chalk.white('Comments:')} ${context.commentCount} comment(s) loaded`);
725
- }
726
- console.log();
938
+ console.log(chalk.green("\n✓ Context loaded\n"));
939
+ console.log(` ${chalk.white("Ticket:")} ${context.taskId}`);
940
+ console.log(` ${chalk.white("Type:")} ${context.taskType}`);
941
+ console.log(
942
+ ` ${chalk.white("Title:")} ${context.title.substring(0, 70)}`,
943
+ );
944
+ console.log(` ${chalk.white("Status:")} ${context.status}`);
945
+ console.log(` ${chalk.white("Assignee:")} ${context.assignee}`);
946
+ if (context.description) {
947
+ console.log(
948
+ ` ${chalk.white("Desc:")} ${context.description.substring(0, 80)}...`,
949
+ );
950
+ }
951
+ if (context.acceptanceCriteria?.length) {
952
+ console.log(
953
+ ` ${chalk.white("Criteria:")} ${context.acceptanceCriteria.length} item(s)`,
954
+ );
955
+ }
956
+ if (context.commentCount > 0) {
957
+ console.log(
958
+ ` ${chalk.white("Comments:")} ${context.commentCount} comment(s) loaded`,
959
+ );
960
+ }
961
+ console.log();
727
962
  }
728
963
 
729
964
  // ── GitNexus index status notification ───────────────────────────
@@ -732,70 +967,90 @@ function printContextSummary(context) {
732
967
  const GITNEXUS_STALE_MS = 3 * 60 * 60 * 1000; // 3 hours — if still "running" after this, process died
733
968
 
734
969
  async function checkGitNexusIndexStatus() {
735
- const statusPath = path.join(AIFLOW_DIR, 'gitnexus-status.json');
736
- try {
737
- if (!(await fs.pathExists(statusPath))) return;
738
- const gn = await fs.readJson(statusPath);
739
- if (gn.notified) return;
740
-
741
- // Detect stale "running" — process was killed without updating status
742
- if (gn.status === 'running' && gn.startedAt) {
743
- const age = Date.now() - new Date(gn.startedAt).getTime();
744
- if (age > GITNEXUS_STALE_MS) {
745
- gn.status = 'error';
746
- gn.error = 'timed out — process may have been killed or ran out of memory';
747
- gn.notified = false;
748
- await fs.writeJson(statusPath, gn, { spaces: 2 });
749
- }
750
- }
751
-
752
- if (gn.status === 'done') {
753
- console.log(chalk.green('✓ GitNexus indexing complete — code intelligence tools are ready.'));
754
- gn.notified = true;
755
- await fs.writeJson(statusPath, gn, { spaces: 2 });
756
- } else if (gn.status === 'error') {
757
- const detail = gn.error ? ` (${gn.error})` : gn.exitCode ? ` (exit ${gn.exitCode})` : '';
758
- console.log(chalk.red(`✗ GitNexus indexing failed${detail}.`));
759
- // console.log(chalk.gray(' Retry: aiflow init --with-gitnexus'));
760
- gn.notified = true;
761
- await fs.writeJson(statusPath, gn, { spaces: 2 });
762
- }
763
- // status === 'running' and not yet stale: still in progress, stay silent
764
- } catch (_) {}
970
+ const statusPath = path.join(AIFLOW_DIR, "gitnexus-status.json");
971
+ try {
972
+ if (!(await fs.pathExists(statusPath))) return;
973
+ const gn = await fs.readJson(statusPath);
974
+ if (gn.notified) return;
975
+
976
+ // Detect stale "running" — process was killed without updating status
977
+ if (gn.status === "running" && gn.startedAt) {
978
+ const age = Date.now() - new Date(gn.startedAt).getTime();
979
+ if (age > GITNEXUS_STALE_MS) {
980
+ gn.status = "error";
981
+ gn.error =
982
+ "timed out — process may have been killed or ran out of memory";
983
+ gn.notified = false;
984
+ await fs.writeJson(statusPath, gn, { spaces: 2 });
985
+ }
986
+ }
987
+
988
+ if (gn.status === "done") {
989
+ console.log(
990
+ chalk.green(
991
+ "✓ GitNexus indexing complete code intelligence tools are ready.",
992
+ ),
993
+ );
994
+ gn.notified = true;
995
+ await fs.writeJson(statusPath, gn, { spaces: 2 });
996
+ } else if (gn.status === "error") {
997
+ const detail = gn.error
998
+ ? ` (${gn.error})`
999
+ : gn.exitCode
1000
+ ? ` (exit ${gn.exitCode})`
1001
+ : "";
1002
+ console.log(chalk.red(`✗ GitNexus indexing failed${detail}.`));
1003
+ // console.log(chalk.gray(' Retry: aiflow init --with-gitnexus'));
1004
+ gn.notified = true;
1005
+ await fs.writeJson(statusPath, gn, { spaces: 2 });
1006
+ }
1007
+ // status === 'running' and not yet stale: still in progress, stay silent
1008
+ } catch (_) {}
765
1009
  }
766
1010
 
767
1011
  async function suggestNextStep() {
768
- let aiTools = ['claude']; // Default
769
- try {
770
- if (await fs.pathExists(STATE_FILE)) {
771
- const state = await fs.readJson(STATE_FILE);
772
- if (state.aiTools && state.aiTools.length) {
773
- aiTools = state.aiTools;
774
- }
775
- }
776
- } catch (_) {}
777
-
778
- console.log(chalk.cyan('\nNext Steps:'));
779
-
780
- const hasCLI = aiTools.includes('claude') || aiTools.includes('gemini');
781
- const hasIDE = aiTools.includes('claude') || aiTools.includes('cursor') || aiTools.includes('copilot');
782
-
783
- if (hasCLI) {
784
- console.log(chalk.white('\n CLI:'));
785
- let step = 0;
786
- if (aiTools.includes('claude')) {
787
- console.log(` ${++step}. ${chalk.white('Claude:')} Run ${chalk.bold.green('claude start')} in terminal. ${chalk.gray('(Quickest way to start)')}`);
788
- }
789
- if (aiTools.includes('gemini')) {
790
- console.log(` ${++step}. ${chalk.white('Gemini:')} Run ${chalk.bold.green('gemini')} then type ${chalk.bold.green('"start"')} or ${chalk.bold.green('"Gate 1"')}.`);
791
- }
1012
+ let aiTools = ["claude"]; // Default
1013
+ try {
1014
+ if (await fs.pathExists(STATE_FILE)) {
1015
+ const state = await fs.readJson(STATE_FILE);
1016
+ if (state.aiTools && state.aiTools.length) {
1017
+ aiTools = state.aiTools;
1018
+ }
792
1019
  }
793
-
794
- if (hasIDE) {
795
- console.log(chalk.white('\n IDE Extension Chat:'));
796
- console.log(` Run ${chalk.bold.green('aiflow prompt')} → prompt auto-copied to clipboard → paste into your AI extension chat.`);
1020
+ } catch (_) {}
1021
+
1022
+ console.log(chalk.cyan("\nNext Steps:"));
1023
+
1024
+ const hasCLI = aiTools.includes("claude") || aiTools.includes("gemini");
1025
+ const hasIDE =
1026
+ aiTools.includes("claude") ||
1027
+ aiTools.includes("cursor") ||
1028
+ aiTools.includes("copilot");
1029
+
1030
+ if (hasCLI) {
1031
+ console.log(chalk.white("\n CLI:"));
1032
+ let step = 0;
1033
+ if (aiTools.includes("claude")) {
1034
+ console.log(
1035
+ ` ${++step}. ${chalk.white("Claude:")} Run ${chalk.bold.green("claude start")} in terminal. ${chalk.gray("(Quickest way to start)")}`,
1036
+ );
797
1037
  }
798
-
799
- 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.`);
800
- console.log();
1038
+ if (aiTools.includes("gemini")) {
1039
+ console.log(
1040
+ ` ${++step}. ${chalk.white("Gemini:")} Run ${chalk.bold.green("gemini")} then type ${chalk.bold.green('"start"')} or ${chalk.bold.green('"Gate 1"')}.`,
1041
+ );
1042
+ }
1043
+ }
1044
+
1045
+ if (hasIDE) {
1046
+ console.log(chalk.white("\n IDE Extension Chat:"));
1047
+ console.log(
1048
+ ` Run ${chalk.bold.green("aiflow prompt")} → prompt auto-copied to clipboard → paste into your AI extension chat.`,
1049
+ );
1050
+ }
1051
+
1052
+ console.log(
1053
+ `\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.`,
1054
+ );
1055
+ console.log();
801
1056
  }