@relipa/ai-flow-kit 0.0.9-beta.1 → 0.1.0-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/README.md +109 -13
- package/bin/aiflow.js +525 -475
- package/custom/mcp-presets/figma-desktop.json +9 -9
- package/custom/rules/project-conventions.md +51 -51
- package/custom/skills/read-study-requirement/SKILL.md +22 -3
- package/custom/templates/shared/gate-workflow.md +13 -12
- package/custom/templates/tools/gemini.md +22 -22
- package/docs/common/CHANGELOG.md +44 -1
- package/docs/common/QUICK_START.md +47 -0
- package/docs/common/cli-reference.md +60 -8
- package/docs/common/workflows/figma.md +105 -105
- package/package.json +10 -3
- package/scripts/create-score-excel.js +354 -0
- package/scripts/guide.js +2 -0
- package/scripts/hooks/session-start.js +315 -295
- package/scripts/hooks/session-stop.js +122 -122
- package/scripts/init.js +17 -2
- package/scripts/link-resolver.js +179 -0
- package/scripts/use.js +117 -11
|
@@ -1,122 +1,122 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* SessionStop hook for ai-flow-kit
|
|
4
|
-
* - Records session telemetry: model, token usage, prompt summary
|
|
5
|
-
* - Prints a token usage summary from checkpoints.json
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
const fs = require('fs');
|
|
9
|
-
const path = require('path');
|
|
10
|
-
const os = require('os');
|
|
11
|
-
|
|
12
|
-
function loadTelemetry() {
|
|
13
|
-
try { return require('../telemetry/record'); } catch (_) {}
|
|
14
|
-
return { record: () => {} };
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function loadTelemetryConfig() {
|
|
18
|
-
try { return require('../telemetry/config'); } catch (_) {}
|
|
19
|
-
return { loadConfig: () => ({}) };
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const { record } = loadTelemetry();
|
|
23
|
-
const { loadConfig } = loadTelemetryConfig();
|
|
24
|
-
|
|
25
|
-
const GATE_LABELS = { 1: 'Analyze', 2: 'Plan', 3: 'Code', 4: 'Review', 5: 'PR' };
|
|
26
|
-
|
|
27
|
-
let raw = '';
|
|
28
|
-
process.stdin.setEncoding('utf-8');
|
|
29
|
-
process.stdin.on('data', chunk => { raw += chunk; });
|
|
30
|
-
process.stdin.on('end', () => {
|
|
31
|
-
let hookData = {};
|
|
32
|
-
try { hookData = JSON.parse(raw || '{}'); } catch (_) {}
|
|
33
|
-
|
|
34
|
-
const cfg = loadConfig();
|
|
35
|
-
let model = '';
|
|
36
|
-
let tokens_input = 0;
|
|
37
|
-
let tokens_output = 0;
|
|
38
|
-
let prompt_summary = '';
|
|
39
|
-
|
|
40
|
-
const transcriptPath = hookData.transcript_path;
|
|
41
|
-
if (transcriptPath && fs.existsSync(transcriptPath)) {
|
|
42
|
-
try {
|
|
43
|
-
const lines = fs.readFileSync(transcriptPath, 'utf-8').split('\n');
|
|
44
|
-
let firstUserDone = false;
|
|
45
|
-
for (const line of lines) {
|
|
46
|
-
if (!line.trim()) continue;
|
|
47
|
-
let entry;
|
|
48
|
-
try { entry = JSON.parse(line); } catch (_) { continue; }
|
|
49
|
-
|
|
50
|
-
if (entry.type === 'user' && !firstUserDone) {
|
|
51
|
-
const content = entry.message && entry.message.content;
|
|
52
|
-
if (Array.isArray(content)) {
|
|
53
|
-
const block = content.find(b => b.type === 'text');
|
|
54
|
-
if (block && block.text) prompt_summary = block.text.substring(0, 200);
|
|
55
|
-
} else if (typeof content === 'string') {
|
|
56
|
-
prompt_summary = content.substring(0, 200);
|
|
57
|
-
}
|
|
58
|
-
firstUserDone = true;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
if (entry.type === 'assistant') {
|
|
62
|
-
const msg = entry.message || {};
|
|
63
|
-
if (msg.usage) {
|
|
64
|
-
tokens_input += msg.usage.input_tokens || 0;
|
|
65
|
-
tokens_output += msg.usage.output_tokens || 0;
|
|
66
|
-
}
|
|
67
|
-
if (!model && msg.model) model = msg.model;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
} catch (_) {}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
record('session.stop', {
|
|
74
|
-
ai_email: cfg.email || '',
|
|
75
|
-
model: model || undefined,
|
|
76
|
-
tokens_input: tokens_input || undefined,
|
|
77
|
-
tokens_output: tokens_output || undefined,
|
|
78
|
-
prompt_summary: prompt_summary || undefined,
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
// ── Token usage summary from checkpoints ──────────────────
|
|
82
|
-
const projectRoot = path.resolve(__dirname, '..', '..');
|
|
83
|
-
const contextFile = path.join(projectRoot, '.aiflow', 'context', 'current.json');
|
|
84
|
-
const tasksDir = path.join(projectRoot, '.aiflow', 'tasks');
|
|
85
|
-
|
|
86
|
-
try {
|
|
87
|
-
if (!fs.existsSync(contextFile)) process.exit(0);
|
|
88
|
-
const ctx = JSON.parse(fs.readFileSync(contextFile, 'utf-8'));
|
|
89
|
-
const ticketId = ctx && ctx.taskId;
|
|
90
|
-
if (!ticketId) process.exit(0);
|
|
91
|
-
|
|
92
|
-
const checkpointPath = path.join(tasksDir, ticketId, 'checkpoints.json');
|
|
93
|
-
if (!fs.existsSync(checkpointPath)) process.exit(0);
|
|
94
|
-
|
|
95
|
-
const data = JSON.parse(fs.readFileSync(checkpointPath, 'utf-8'));
|
|
96
|
-
const checkpoints = data.checkpoints || [];
|
|
97
|
-
if (checkpoints.length === 0) process.exit(0);
|
|
98
|
-
|
|
99
|
-
const gateTokens = {};
|
|
100
|
-
for (const cp of checkpoints) {
|
|
101
|
-
const g = cp.gate;
|
|
102
|
-
if (!gateTokens[g] || cp.tokens > gateTokens[g]) {
|
|
103
|
-
gateTokens[g] = cp.tokens;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const gates = Object.keys(gateTokens).sort();
|
|
108
|
-
const total = gates.reduce((sum, g) => sum + gateTokens[g], 0);
|
|
109
|
-
|
|
110
|
-
process.stderr.write('\n[aiflow] Token usage this session:\n');
|
|
111
|
-
for (const g of gates) {
|
|
112
|
-
const label = GATE_LABELS[parseInt(g)] || `Gate ${g}`;
|
|
113
|
-
const t = gateTokens[g];
|
|
114
|
-
const bar = '█'.repeat(Math.round((t / total) * 20)).padEnd(20, '░');
|
|
115
|
-
const pct = Math.round((t / total) * 100);
|
|
116
|
-
process.stderr.write(`[aiflow] Gate ${g} ${label.padEnd(8)} ~${t.toLocaleString().padStart(6)} ${bar} ${pct}%\n`);
|
|
117
|
-
}
|
|
118
|
-
process.stderr.write(`[aiflow] Total estimated: ~${total.toLocaleString()} tokens\n\n`);
|
|
119
|
-
} catch (_) {}
|
|
120
|
-
|
|
121
|
-
process.exit(0);
|
|
122
|
-
});
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SessionStop hook for ai-flow-kit
|
|
4
|
+
* - Records session telemetry: model, token usage, prompt summary
|
|
5
|
+
* - Prints a token usage summary from checkpoints.json
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
|
|
12
|
+
function loadTelemetry() {
|
|
13
|
+
try { return require('../telemetry/record'); } catch (_) {}
|
|
14
|
+
return { record: () => {} };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function loadTelemetryConfig() {
|
|
18
|
+
try { return require('../telemetry/config'); } catch (_) {}
|
|
19
|
+
return { loadConfig: () => ({}) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const { record } = loadTelemetry();
|
|
23
|
+
const { loadConfig } = loadTelemetryConfig();
|
|
24
|
+
|
|
25
|
+
const GATE_LABELS = { 1: 'Analyze', 2: 'Plan', 3: 'Code', 4: 'Review', 5: 'PR' };
|
|
26
|
+
|
|
27
|
+
let raw = '';
|
|
28
|
+
process.stdin.setEncoding('utf-8');
|
|
29
|
+
process.stdin.on('data', chunk => { raw += chunk; });
|
|
30
|
+
process.stdin.on('end', () => {
|
|
31
|
+
let hookData = {};
|
|
32
|
+
try { hookData = JSON.parse(raw || '{}'); } catch (_) {}
|
|
33
|
+
|
|
34
|
+
const cfg = loadConfig();
|
|
35
|
+
let model = '';
|
|
36
|
+
let tokens_input = 0;
|
|
37
|
+
let tokens_output = 0;
|
|
38
|
+
let prompt_summary = '';
|
|
39
|
+
|
|
40
|
+
const transcriptPath = hookData.transcript_path;
|
|
41
|
+
if (transcriptPath && fs.existsSync(transcriptPath)) {
|
|
42
|
+
try {
|
|
43
|
+
const lines = fs.readFileSync(transcriptPath, 'utf-8').split('\n');
|
|
44
|
+
let firstUserDone = false;
|
|
45
|
+
for (const line of lines) {
|
|
46
|
+
if (!line.trim()) continue;
|
|
47
|
+
let entry;
|
|
48
|
+
try { entry = JSON.parse(line); } catch (_) { continue; }
|
|
49
|
+
|
|
50
|
+
if (entry.type === 'user' && !firstUserDone) {
|
|
51
|
+
const content = entry.message && entry.message.content;
|
|
52
|
+
if (Array.isArray(content)) {
|
|
53
|
+
const block = content.find(b => b.type === 'text');
|
|
54
|
+
if (block && block.text) prompt_summary = block.text.substring(0, 200);
|
|
55
|
+
} else if (typeof content === 'string') {
|
|
56
|
+
prompt_summary = content.substring(0, 200);
|
|
57
|
+
}
|
|
58
|
+
firstUserDone = true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (entry.type === 'assistant') {
|
|
62
|
+
const msg = entry.message || {};
|
|
63
|
+
if (msg.usage) {
|
|
64
|
+
tokens_input += msg.usage.input_tokens || 0;
|
|
65
|
+
tokens_output += msg.usage.output_tokens || 0;
|
|
66
|
+
}
|
|
67
|
+
if (!model && msg.model) model = msg.model;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} catch (_) {}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
record('session.stop', {
|
|
74
|
+
ai_email: cfg.email || '',
|
|
75
|
+
model: model || undefined,
|
|
76
|
+
tokens_input: tokens_input || undefined,
|
|
77
|
+
tokens_output: tokens_output || undefined,
|
|
78
|
+
prompt_summary: prompt_summary || undefined,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ── Token usage summary from checkpoints ──────────────────
|
|
82
|
+
const projectRoot = path.resolve(__dirname, '..', '..');
|
|
83
|
+
const contextFile = path.join(projectRoot, '.aiflow', 'context', 'current.json');
|
|
84
|
+
const tasksDir = path.join(projectRoot, '.aiflow', 'tasks');
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
if (!fs.existsSync(contextFile)) process.exit(0);
|
|
88
|
+
const ctx = JSON.parse(fs.readFileSync(contextFile, 'utf-8'));
|
|
89
|
+
const ticketId = ctx && ctx.taskId;
|
|
90
|
+
if (!ticketId) process.exit(0);
|
|
91
|
+
|
|
92
|
+
const checkpointPath = path.join(tasksDir, ticketId, 'checkpoints.json');
|
|
93
|
+
if (!fs.existsSync(checkpointPath)) process.exit(0);
|
|
94
|
+
|
|
95
|
+
const data = JSON.parse(fs.readFileSync(checkpointPath, 'utf-8'));
|
|
96
|
+
const checkpoints = data.checkpoints || [];
|
|
97
|
+
if (checkpoints.length === 0) process.exit(0);
|
|
98
|
+
|
|
99
|
+
const gateTokens = {};
|
|
100
|
+
for (const cp of checkpoints) {
|
|
101
|
+
const g = cp.gate;
|
|
102
|
+
if (!gateTokens[g] || cp.tokens > gateTokens[g]) {
|
|
103
|
+
gateTokens[g] = cp.tokens;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const gates = Object.keys(gateTokens).sort();
|
|
108
|
+
const total = gates.reduce((sum, g) => sum + gateTokens[g], 0);
|
|
109
|
+
|
|
110
|
+
process.stderr.write('\n[aiflow] Token usage this session:\n');
|
|
111
|
+
for (const g of gates) {
|
|
112
|
+
const label = GATE_LABELS[parseInt(g)] || `Gate ${g}`;
|
|
113
|
+
const t = gateTokens[g];
|
|
114
|
+
const bar = '█'.repeat(Math.round((t / total) * 20)).padEnd(20, '░');
|
|
115
|
+
const pct = Math.round((t / total) * 100);
|
|
116
|
+
process.stderr.write(`[aiflow] Gate ${g} ${label.padEnd(8)} ~${t.toLocaleString().padStart(6)} ${bar} ${pct}%\n`);
|
|
117
|
+
}
|
|
118
|
+
process.stderr.write(`[aiflow] Total estimated: ~${total.toLocaleString()} tokens\n\n`);
|
|
119
|
+
} catch (_) {}
|
|
120
|
+
|
|
121
|
+
process.exit(0);
|
|
122
|
+
});
|
package/scripts/init.js
CHANGED
|
@@ -903,9 +903,22 @@ async function init(options) {
|
|
|
903
903
|
|
|
904
904
|
// ── Auto-detect or prompt for framework if not supplied ──────
|
|
905
905
|
if (frameworks.length === 0) {
|
|
906
|
+
const stateFilePath = path.join(aiflowDir, 'state.json');
|
|
907
|
+
let previousFrameworks = [];
|
|
908
|
+
if (await fs.pathExists(stateFilePath)) {
|
|
909
|
+
const savedState = await fs.readJson(stateFilePath);
|
|
910
|
+
previousFrameworks = savedState.frameworks || [];
|
|
911
|
+
}
|
|
912
|
+
|
|
906
913
|
const detected = await detectFramework(projectDir);
|
|
907
914
|
const hint = chalk.gray(' Space to select/unselect · Enter to confirm · Leave empty to skip');
|
|
908
|
-
if (
|
|
915
|
+
if (previousFrameworks.length > 0) {
|
|
916
|
+
const labels = previousFrameworks
|
|
917
|
+
.map(v => FRAMEWORK_CHOICES.find(c => c.value === v)?.name || v)
|
|
918
|
+
.join(', ');
|
|
919
|
+
console.log(chalk.cyan(`\n Previously selected: ${chalk.bold(labels)}`));
|
|
920
|
+
console.log(hint);
|
|
921
|
+
} else if (detected) {
|
|
909
922
|
const label = FRAMEWORK_CHOICES.find(c => c.value === detected)?.name || detected;
|
|
910
923
|
console.log(chalk.cyan(`\n Detected framework: ${chalk.bold(label)}`));
|
|
911
924
|
console.log(hint);
|
|
@@ -917,7 +930,9 @@ async function init(options) {
|
|
|
917
930
|
message: 'Select framework(s):',
|
|
918
931
|
choices: FRAMEWORK_CHOICES.map(c => ({
|
|
919
932
|
...c,
|
|
920
|
-
checked:
|
|
933
|
+
checked: previousFrameworks.length > 0
|
|
934
|
+
? previousFrameworks.includes(c.value)
|
|
935
|
+
: c.value === detected,
|
|
921
936
|
})),
|
|
922
937
|
});
|
|
923
938
|
frameworks = chosen;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const https = require('https');
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
|
|
5
|
+
const BACKLOG_RE = /https?:\/\/([\w.-]+\.backlog(?:tool)?\.com)\/view\/([A-Z][A-Z0-9_]+-\d+)(?:#comment-(\d+))?/i;
|
|
6
|
+
const JIRA_RE = /https?:\/\/([\w.-]+\.atlassian\.net)\/browse\/([A-Z][A-Z0-9_]+-\d+)/i;
|
|
7
|
+
const JIRA_COMMENT_RE = /[?&]focusedCommentId=(\d+)/;
|
|
8
|
+
const URL_RE = /https?:\/\/[^\s\)\]"<>]+/g;
|
|
9
|
+
|
|
10
|
+
const MAX_AUTO_LINKS = 5;
|
|
11
|
+
|
|
12
|
+
function classifyLink(url) {
|
|
13
|
+
const bm = url.match(BACKLOG_RE);
|
|
14
|
+
if (bm) {
|
|
15
|
+
const [, domain, ticketId, commentId] = bm;
|
|
16
|
+
return { adapter: 'backlog', domain, ticketId, commentId: commentId || null, isComment: !!commentId };
|
|
17
|
+
}
|
|
18
|
+
const jm = url.match(JIRA_RE);
|
|
19
|
+
if (jm) {
|
|
20
|
+
const [, domain, ticketId] = jm;
|
|
21
|
+
const cm = url.match(JIRA_COMMENT_RE);
|
|
22
|
+
const commentId = cm ? cm[1] : null;
|
|
23
|
+
return { adapter: 'jira', domain, ticketId, commentId, isComment: !!commentId };
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function scanLinks(text) {
|
|
29
|
+
const found = [];
|
|
30
|
+
let m;
|
|
31
|
+
URL_RE.lastIndex = 0;
|
|
32
|
+
while ((m = URL_RE.exec(text)) !== null) {
|
|
33
|
+
const classified = classifyLink(m[0]);
|
|
34
|
+
if (classified) found.push({ url: m[0], ...classified });
|
|
35
|
+
}
|
|
36
|
+
return found;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function httpsGet(url, headers) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const opts = headers ? { headers } : {};
|
|
42
|
+
https.get(url, opts, (res) => {
|
|
43
|
+
let data = '';
|
|
44
|
+
res.on('data', chunk => (data += chunk));
|
|
45
|
+
res.on('end', () => {
|
|
46
|
+
if (res.statusCode !== 200) {
|
|
47
|
+
reject(new Error(`HTTP ${res.statusCode}`));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
try { resolve(JSON.parse(data)); }
|
|
51
|
+
catch (e) { reject(new Error('Invalid JSON from API')); }
|
|
52
|
+
});
|
|
53
|
+
}).on('error', reject);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function fetchBacklogTicket(domain, apiKey, ticketId) {
|
|
58
|
+
const issue = await httpsGet(`https://${domain}/api/v2/issues/${ticketId}?apiKey=${apiKey}`);
|
|
59
|
+
return {
|
|
60
|
+
sourceType: 'ticket',
|
|
61
|
+
sourceUrl: `https://${domain}/view/${ticketId}`,
|
|
62
|
+
ticketId,
|
|
63
|
+
title: issue.summary || '',
|
|
64
|
+
description: (issue.description || '').substring(0, 2000),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function fetchBacklogComment(domain, apiKey, ticketId, commentId) {
|
|
69
|
+
const comments = await httpsGet(
|
|
70
|
+
`https://${domain}/api/v2/issues/${ticketId}/comments?apiKey=${apiKey}&count=100&order=asc`
|
|
71
|
+
);
|
|
72
|
+
const comment = (comments || []).find(c => String(c.id) === String(commentId));
|
|
73
|
+
if (!comment) throw new Error(`Comment ${commentId} not found in ${ticketId}`);
|
|
74
|
+
const date = comment.created ? new Date(comment.created).toLocaleDateString('vi-VN') : '';
|
|
75
|
+
return {
|
|
76
|
+
sourceType: 'comment',
|
|
77
|
+
sourceUrl: `https://${domain}/view/${ticketId}#comment-${commentId}`,
|
|
78
|
+
ticketId,
|
|
79
|
+
commentId: String(commentId),
|
|
80
|
+
author: comment.createdUser?.name || 'Unknown',
|
|
81
|
+
date,
|
|
82
|
+
content: (comment.content || '').substring(0, 2000),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function jiraAuth(email, token) {
|
|
87
|
+
return `Basic ${Buffer.from(`${email}:${token}`).toString('base64')}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function extractJiraText(descField) {
|
|
91
|
+
if (!descField) return '';
|
|
92
|
+
return (descField.content || [])
|
|
93
|
+
.map(block => (block.content || []).map(c => c.text || '').join(''))
|
|
94
|
+
.join('\n');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function fetchJiraTicket(domain, email, token, ticketId) {
|
|
98
|
+
const issue = await httpsGet(
|
|
99
|
+
`https://${domain}/rest/api/3/issue/${ticketId}`,
|
|
100
|
+
{ Authorization: jiraAuth(email, token), Accept: 'application/json' }
|
|
101
|
+
);
|
|
102
|
+
const fields = issue.fields || {};
|
|
103
|
+
return {
|
|
104
|
+
sourceType: 'ticket',
|
|
105
|
+
sourceUrl: `https://${domain}/browse/${ticketId}`,
|
|
106
|
+
ticketId,
|
|
107
|
+
title: fields.summary || '',
|
|
108
|
+
description: extractJiraText(fields.description).substring(0, 2000),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function fetchJiraComment(domain, email, token, ticketId, commentId) {
|
|
113
|
+
const data = await httpsGet(
|
|
114
|
+
`https://${domain}/rest/api/3/issue/${ticketId}/comment/${commentId}`,
|
|
115
|
+
{ Authorization: jiraAuth(email, token), Accept: 'application/json' }
|
|
116
|
+
);
|
|
117
|
+
const date = data.created ? new Date(data.created).toLocaleDateString('vi-VN') : '';
|
|
118
|
+
return {
|
|
119
|
+
sourceType: 'comment',
|
|
120
|
+
sourceUrl: `https://${domain}/browse/${ticketId}?focusedCommentId=${commentId}`,
|
|
121
|
+
ticketId,
|
|
122
|
+
commentId: String(commentId),
|
|
123
|
+
author: data.author?.displayName || 'Unknown',
|
|
124
|
+
date,
|
|
125
|
+
content: extractJiraText(data.body).substring(0, 2000),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function fetchLink(url, credentials = {}, alreadyLoadedIds = []) {
|
|
130
|
+
const classified = classifyLink(url);
|
|
131
|
+
if (!classified) return null;
|
|
132
|
+
|
|
133
|
+
const { adapter, domain, ticketId, commentId, isComment } = classified;
|
|
134
|
+
const dedupeKey = ticketId + (commentId ? '#' + commentId : '');
|
|
135
|
+
if (alreadyLoadedIds.includes(dedupeKey) || alreadyLoadedIds.includes(ticketId)) return null;
|
|
136
|
+
|
|
137
|
+
if (adapter === 'backlog') {
|
|
138
|
+
const apiKey = credentials.BACKLOG_API_KEY;
|
|
139
|
+
if (!apiKey) throw new Error('BACKLOG_API_KEY not set');
|
|
140
|
+
if (isComment) return await fetchBacklogComment(domain, apiKey, ticketId, commentId);
|
|
141
|
+
return await fetchBacklogTicket(domain, apiKey, ticketId);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (adapter === 'jira') {
|
|
145
|
+
const email = credentials.JIRA_EMAIL;
|
|
146
|
+
const token = credentials.JIRA_API_TOKEN;
|
|
147
|
+
if (!email || !token) throw new Error('JIRA_EMAIL or JIRA_API_TOKEN not set');
|
|
148
|
+
if (isComment) return await fetchJiraComment(domain, email, token, ticketId, commentId);
|
|
149
|
+
return await fetchJiraTicket(domain, email, token, ticketId);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function resolveLinks(description, credentials = {}, alreadyLoadedIds = []) {
|
|
156
|
+
const links = scanLinks(description).slice(0, MAX_AUTO_LINKS);
|
|
157
|
+
if (!links.length) return [];
|
|
158
|
+
|
|
159
|
+
const results = [];
|
|
160
|
+
const seen = [...alreadyLoadedIds];
|
|
161
|
+
|
|
162
|
+
for (const link of links) {
|
|
163
|
+
const dedupeKey = link.ticketId + (link.commentId ? '#' + link.commentId : '');
|
|
164
|
+
if (seen.includes(dedupeKey) || seen.includes(link.ticketId)) continue;
|
|
165
|
+
try {
|
|
166
|
+
const item = await fetchLink(link.url, credentials, seen);
|
|
167
|
+
if (item) {
|
|
168
|
+
results.push(item);
|
|
169
|
+
seen.push(dedupeKey);
|
|
170
|
+
console.log(chalk.gray(` ✓ Auto-resolved: ${link.ticketId}${link.commentId ? '#' + link.commentId : ''}`));
|
|
171
|
+
}
|
|
172
|
+
} catch (err) {
|
|
173
|
+
console.log(chalk.yellow(` ⚠ Could not resolve ${link.url}: ${err.message}`));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return results;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = { classifyLink, scanLinks, fetchBacklogTicket, fetchBacklogComment, fetchJiraTicket, fetchJiraComment, fetchLink, resolveLinks };
|
package/scripts/use.js
CHANGED
|
@@ -14,7 +14,12 @@ const CONTEXT_DIR = path.join(PROJECT_DIR, ".aiflow", "context");
|
|
|
14
14
|
// Entry point
|
|
15
15
|
// ──────────────────────────────────────────────────────────────
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
const useCommand = async function use(targets, options = {}) {
|
|
18
|
+
// Normalize: accept string (legacy) or array (new multi-target)
|
|
19
|
+
const targetList = Array.isArray(targets) ? targets.filter(Boolean) : (targets ? [targets] : []);
|
|
20
|
+
const primaryTarget = targetList[0] || null;
|
|
21
|
+
const supplementaryTargets = targetList.slice(1);
|
|
22
|
+
|
|
18
23
|
if (!(await fs.pathExists(STATE_FILE))) {
|
|
19
24
|
console.log(
|
|
20
25
|
chalk.red("Project is not initialized. Run `aiflow init` first."),
|
|
@@ -36,28 +41,30 @@ module.exports = async function use(target, options = {}) {
|
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
// ── No target → version switcher ──
|
|
39
|
-
if (!
|
|
44
|
+
if (!primaryTarget) {
|
|
40
45
|
return await switchVersion();
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
// ── Detect target type and dispatch ──
|
|
44
|
-
if (isVersion(
|
|
45
|
-
return await switchVersion(
|
|
49
|
+
if (isVersion(primaryTarget)) {
|
|
50
|
+
return await switchVersion(primaryTarget);
|
|
46
51
|
}
|
|
47
52
|
|
|
48
|
-
if (isTicketId(
|
|
53
|
+
if (isTicketId(primaryTarget)) {
|
|
49
54
|
const adapter = await resolveTicketAdapter();
|
|
50
|
-
if (adapter === "jira")
|
|
51
|
-
|
|
55
|
+
if (adapter === "jira") await loadFromJira(primaryTarget, options);
|
|
56
|
+
else await loadFromBacklog(primaryTarget, options);
|
|
57
|
+
return await enrichWithSupplementary(supplementaryTargets);
|
|
52
58
|
}
|
|
53
59
|
|
|
54
|
-
if (isBacklogUrl(
|
|
55
|
-
const id = extractBacklogId(
|
|
56
|
-
|
|
60
|
+
if (isBacklogUrl(primaryTarget)) {
|
|
61
|
+
const id = extractBacklogId(primaryTarget);
|
|
62
|
+
await loadFromBacklog(id, options);
|
|
63
|
+
return await enrichWithSupplementary(supplementaryTargets);
|
|
57
64
|
}
|
|
58
65
|
|
|
59
66
|
// ── Fallback: try Backlog then manual ──
|
|
60
|
-
console.log(chalk.yellow(`Cannot detect format for: ${
|
|
67
|
+
console.log(chalk.yellow(`Cannot detect format for: ${primaryTarget}`));
|
|
61
68
|
console.log(
|
|
62
69
|
chalk.gray("Supported: PROJ-33, APP-123, version number, --manual"),
|
|
63
70
|
);
|
|
@@ -1054,3 +1061,102 @@ async function suggestNextStep() {
|
|
|
1054
1061
|
);
|
|
1055
1062
|
console.log();
|
|
1056
1063
|
}
|
|
1064
|
+
|
|
1065
|
+
async function loadTargetAsSupplementaryContext(target, creds, alreadyIds) {
|
|
1066
|
+
if (!target) return null;
|
|
1067
|
+
|
|
1068
|
+
// File path (not a ticket ID, not a backlog URL)
|
|
1069
|
+
if (!isTicketId(target) && !isBacklogUrl(target)) {
|
|
1070
|
+
try {
|
|
1071
|
+
const raw = await fs.readFile(target, 'utf-8');
|
|
1072
|
+
return {
|
|
1073
|
+
sourceType: 'file',
|
|
1074
|
+
sourcePath: target,
|
|
1075
|
+
title: path.basename(target),
|
|
1076
|
+
content: raw.substring(0, 3000),
|
|
1077
|
+
};
|
|
1078
|
+
} catch (err) {
|
|
1079
|
+
console.log(chalk.yellow(` ⚠ Could not read file: ${target}`));
|
|
1080
|
+
return null;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
const issueKey = isBacklogUrl(target) ? extractBacklogId(target) : target;
|
|
1085
|
+
if (!issueKey || alreadyIds.includes(issueKey)) return null;
|
|
1086
|
+
|
|
1087
|
+
const adapter = await resolveTicketAdapter();
|
|
1088
|
+
|
|
1089
|
+
try {
|
|
1090
|
+
if (adapter === 'jira') {
|
|
1091
|
+
const token = creds.JIRA_API_TOKEN;
|
|
1092
|
+
const email = creds.JIRA_EMAIL;
|
|
1093
|
+
const domain = creds.JIRA_DOMAIN;
|
|
1094
|
+
if (!token || !email || !domain) return null;
|
|
1095
|
+
const issue = await fetchJiraIssue(domain, email, token, issueKey);
|
|
1096
|
+
const fields = issue.fields || {};
|
|
1097
|
+
const desc = (fields.description?.content || [])
|
|
1098
|
+
.map(b => (b.content || []).map(c => c.text || '').join('')).join('\n');
|
|
1099
|
+
return {
|
|
1100
|
+
sourceType: 'ticket',
|
|
1101
|
+
sourceUrl: `https://${domain}.atlassian.net/browse/${issueKey}`,
|
|
1102
|
+
ticketId: issueKey,
|
|
1103
|
+
title: fields.summary || '',
|
|
1104
|
+
description: desc.substring(0, 2000),
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
const apiKey = creds.BACKLOG_API_KEY;
|
|
1108
|
+
const spaceKey = creds.BACKLOG_SPACE_KEY || creds.BACKLOG_DOMAIN;
|
|
1109
|
+
if (!apiKey || !spaceKey) return null;
|
|
1110
|
+
const domain = spaceKey.includes('.') ? spaceKey : `${spaceKey}.backlog.com`;
|
|
1111
|
+
const issue = await fetchBacklogIssue(domain, apiKey, issueKey);
|
|
1112
|
+
return {
|
|
1113
|
+
sourceType: 'ticket',
|
|
1114
|
+
sourceUrl: `https://${domain}/view/${issueKey}`,
|
|
1115
|
+
ticketId: issueKey,
|
|
1116
|
+
title: issue.summary || '',
|
|
1117
|
+
description: (issue.description || '').substring(0, 2000),
|
|
1118
|
+
};
|
|
1119
|
+
} catch (err) {
|
|
1120
|
+
console.log(chalk.yellow(` ⚠ Could not load supplementary ${issueKey}: ${err.message}`));
|
|
1121
|
+
return null;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
async function enrichWithSupplementary(supplementaryTargets) {
|
|
1126
|
+
const currentFile = path.join(CONTEXT_DIR, 'current.json');
|
|
1127
|
+
if (!(await fs.pathExists(currentFile))) return;
|
|
1128
|
+
|
|
1129
|
+
const ctx = await fs.readJson(currentFile).catch(() => null);
|
|
1130
|
+
if (!ctx) return;
|
|
1131
|
+
|
|
1132
|
+
const creds = await loadCredentials();
|
|
1133
|
+
const supplementary = [];
|
|
1134
|
+
const alreadyIds = [ctx.taskId].filter(Boolean);
|
|
1135
|
+
|
|
1136
|
+
for (const t of supplementaryTargets) {
|
|
1137
|
+
const sc = await loadTargetAsSupplementaryContext(t, creds, alreadyIds);
|
|
1138
|
+
if (sc) {
|
|
1139
|
+
supplementary.push(sc);
|
|
1140
|
+
if (sc.ticketId) alreadyIds.push(sc.ticketId);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
if (ctx.description) {
|
|
1145
|
+
const { resolveLinks } = require('./link-resolver');
|
|
1146
|
+
const linked = await resolveLinks(ctx.description, creds, alreadyIds);
|
|
1147
|
+
supplementary.push(...linked);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
if (supplementary.length > 0) {
|
|
1151
|
+
ctx.supplementaryContext = supplementary;
|
|
1152
|
+
await fs.writeJson(currentFile, ctx, { spaces: 2 });
|
|
1153
|
+
const histFile = path.join(CONTEXT_DIR, 'history', `${ctx.taskId || 'manual'}.json`);
|
|
1154
|
+
if (await fs.pathExists(path.dirname(histFile))) {
|
|
1155
|
+
await fs.writeJson(histFile, ctx, { spaces: 2 });
|
|
1156
|
+
}
|
|
1157
|
+
console.log(chalk.green(` ✓ ${supplementary.length} supplementary context(s) linked`));
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
module.exports = useCommand;
|
|
1162
|
+
module.exports.loadCredentials = loadCredentials;
|