@relipa/ai-flow-kit 0.2.0-beta.0 → 0.2.0-beta.2

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 (48) hide show
  1. package/bin/aiflow.js +64 -4
  2. package/custom/rules/output-language.md +36 -0
  3. package/custom/skills/automation-testing/SKILL.md +1 -1
  4. package/custom/skills/ba-skills/skill-ba-build-business-rules-v1.md +2 -0
  5. package/custom/skills/ba-skills/skill-ba-initial-analysis-template-v1.md +7 -5
  6. package/custom/skills/ba-skills/skill-ba-initial-analysis-v1.md +8 -1
  7. package/custom/skills/ba-skills/skill-ba-mermaid-flowchart-v1.md +2 -0
  8. package/custom/skills/ba-skills/skill-ba-qna-template-v1.md +6 -5
  9. package/custom/skills/ba-skills/skill-ba-qna-v1.md +19 -5
  10. package/custom/skills/ba-skills/skill-ba-ui-prototype-v1.md +2 -0
  11. package/custom/skills/ba-skills/skill-ba-write-uc-spec-v1.md +2 -0
  12. package/custom/skills/coverage-check/SKILL.md +2 -0
  13. package/custom/skills/create-system-requirement/SKILL.md +115 -33
  14. package/custom/skills/deploy-model/SKILL.md +2 -0
  15. package/custom/skills/design-experiment/SKILL.md +2 -0
  16. package/custom/skills/evaluate-model/SKILL.md +2 -0
  17. package/custom/skills/evidence-aggregation/SKILL.md +2 -0
  18. package/custom/skills/execute-flow/SKILL.md +2 -0
  19. package/custom/skills/explore-data/SKILL.md +2 -0
  20. package/custom/skills/frame-ml-problem/SKILL.md +2 -0
  21. package/custom/skills/gate-review/SKILL.md +2 -0
  22. package/custom/skills/generate-spec/SKILL.md +2 -0
  23. package/custom/skills/generate-test-report/SKILL.md +2 -0
  24. package/custom/skills/generate-testcase/SKILL.md +2 -0
  25. package/custom/skills/impact-analysis/SKILL.md +2 -0
  26. package/custom/skills/improve-algorithm/SKILL.md +2 -0
  27. package/custom/skills/ingest-data/SKILL.md +117 -3
  28. package/custom/skills/log-bug/SKILL.md +2 -0
  29. package/custom/skills/pr-impact-analysis/SKILL.md +2 -0
  30. package/custom/skills/read-study-requirement/SKILL.md +13 -7
  31. package/custom/skills/report-customer/SKILL.md +6 -2
  32. package/custom/skills/retest-orchestration/SKILL.md +2 -0
  33. package/custom/skills/review-plan/SKILL.md +2 -0
  34. package/custom/skills/script-sync/SKILL.md +1 -1
  35. package/custom/skills/test-analysis/SKILL.md +2 -0
  36. package/custom/skills/test-skills/rules/qa-writing-standards.md +2 -2
  37. package/custom/skills/train-model/SKILL.md +2 -0
  38. package/custom/templates/shared/create-spec-workflow.md +93 -31
  39. package/custom/templates/shared/create-testcase-workflow.md +32 -1
  40. package/custom/templates/shared/gate-workflow.md +15 -0
  41. package/docs/common/CHANGELOG.md +23 -0
  42. package/package.json +1 -1
  43. package/scripts/create-score-excel.js +4 -111
  44. package/scripts/init.js +24 -1
  45. package/scripts/link-resolver.js +101 -4
  46. package/scripts/task.js +25 -8
  47. package/scripts/ticket-writer.js +247 -0
  48. package/scripts/use.js +141 -0
@@ -3,9 +3,13 @@ const https = require('https');
3
3
  const chalk = require('chalk');
4
4
 
5
5
  const BACKLOG_RE = /https?:\/\/([\w.-]+\.backlog(?:tool)?\.com)\/view\/([A-Z][A-Z0-9_]+-\d+)(?:#comment-(\d+))?/i;
6
- // Backlog Document permalink — "Copy link" on a Document page gives /alias/document/{id}.
7
- // NOTE: Nulab's Document REST API is new (2026) — path unverified against a live space, adjust if it 404s.
8
- const BACKLOG_DOCUMENT_RE = /https?:\/\/([\w.-]+\.backlog(?:tool)?\.com)\/(?:alias\/)?document\/(\d+)(?:#comment-(\d+))?/i;
6
+ // Backlog Document permalink — "Copy link" on a Document page gives /alias/document/{id},
7
+ // where {id} is Nulab's hex document ID (e.g. 0193b335c62173de9547bab5dd0b5324), not numeric.
8
+ // Newer "Share link" format adds a project-key + /e/ prefix: /document/{PROJECT_KEY}/e/{id}.
9
+ // Confirmed live against a real space (2026-08-06): GET /api/v2/documents/{id} returns the
10
+ // body as `plain` (not `plainText`/`content`) — see fetchBacklogDocument below.
11
+ // Comment fetch (fetchBacklogDocumentComment) is still unverified — adjust its field names if it 404s or returns empty content.
12
+ const BACKLOG_DOCUMENT_RE = /https?:\/\/([\w.-]+\.backlog(?:tool)?\.com)\/(?:alias\/)?document\/(?:[A-Z][A-Z0-9_]*\/e\/)?([0-9a-f]+)(?:#comment-(\d+))?/i;
9
13
  // Backlog Wiki permalink — "Copy link" on a Wiki page gives /alias/wiki/{id}.
10
14
  const BACKLOG_WIKI_RE = /https?:\/\/([\w.-]+\.backlog(?:tool)?\.com)\/alias\/wiki\/(\d+)/i;
11
15
  const JIRA_RE = /https?:\/\/([\w.-]+\.atlassian\.net)\/browse\/([A-Z][A-Z0-9_]+-\d+)/i;
@@ -78,6 +82,41 @@ function httpsGet(url, headers) {
78
82
  });
79
83
  }
80
84
 
85
+ // Generic POST — `bodyType` picks the wire format:
86
+ // 'form' → application/x-www-form-urlencoded (Backlog's write API)
87
+ // 'json' → application/json (Jira's write API)
88
+ function httpsPost(url, payload, { headers = {}, bodyType = 'json' } = {}) {
89
+ return new Promise((resolve, reject) => {
90
+ const body = bodyType === 'form'
91
+ ? new URLSearchParams(payload).toString()
92
+ : JSON.stringify(payload);
93
+ const reqHeaders = {
94
+ ...headers,
95
+ 'Content-Type': bodyType === 'form' ? 'application/x-www-form-urlencoded' : 'application/json',
96
+ 'Content-Length': Buffer.byteLength(body),
97
+ };
98
+ const { hostname, pathname, search } = new URL(url);
99
+ const req = https.request(
100
+ { hostname, path: pathname + search, method: 'POST', headers: reqHeaders },
101
+ (res) => {
102
+ let data = '';
103
+ res.on('data', chunk => (data += chunk));
104
+ res.on('end', () => {
105
+ if (res.statusCode < 200 || res.statusCode >= 300) {
106
+ reject(new Error(`HTTP ${res.statusCode}: ${data.substring(0, 500)}`));
107
+ return;
108
+ }
109
+ try { resolve(data ? JSON.parse(data) : {}); }
110
+ catch (e) { reject(new Error('Invalid JSON from API')); }
111
+ });
112
+ }
113
+ );
114
+ req.on('error', reject);
115
+ req.write(body);
116
+ req.end();
117
+ });
118
+ }
119
+
81
120
  async function fetchBacklogTicket(domain, apiKey, ticketId) {
82
121
  const issue = await httpsGet(`https://${domain}/api/v2/issues/${ticketId}?apiKey=${apiKey}`);
83
122
  return {
@@ -115,7 +154,7 @@ async function fetchBacklogDocument(domain, apiKey, documentId) {
115
154
  documentId: String(documentId),
116
155
  title: doc.title || doc.name || '',
117
156
  tag: (doc.tags || []).map(t => t.name).join(', '),
118
- content: (doc.plainText || doc.content || '').substring(0, 4000),
157
+ content: (doc.plain || doc.plainText || doc.content || '').substring(0, 4000),
119
158
  };
120
159
  }
121
160
 
@@ -148,6 +187,36 @@ async function fetchBacklogWiki(domain, apiKey, wikiId) {
148
187
  };
149
188
  }
150
189
 
190
+ // ── Backlog WRITE — issue creation (PM Workflow_v1.0.md "Vấn đề 5") ──────────
191
+
192
+ async function fetchBacklogIssueTypes(domain, apiKey, projectIdOrKey) {
193
+ return httpsGet(`https://${domain}/api/v2/projects/${projectIdOrKey}/issueTypes?apiKey=${apiKey}`);
194
+ }
195
+
196
+ async function fetchBacklogPriorities(domain, apiKey) {
197
+ return httpsGet(`https://${domain}/api/v2/priorities?apiKey=${apiKey}`);
198
+ }
199
+
200
+ /**
201
+ * Creates a Backlog issue. Requires an apiKey with WRITE permission — distinct from the
202
+ * read-only key used elsewhere in this file for `ak fetch-links`/`ak use`.
203
+ * `projectId`, `summary`, `issueTypeId`, `priorityId` are mandatory per Backlog's API.
204
+ */
205
+ async function createBacklogIssue(domain, apiKeyWrite, { projectId, summary, description, issueTypeId, priorityId }) {
206
+ const issue = await httpsPost(
207
+ `https://${domain}/api/v2/issues?apiKey=${apiKeyWrite}`,
208
+ {
209
+ projectId: String(projectId),
210
+ summary,
211
+ issueTypeId: String(issueTypeId),
212
+ priorityId: String(priorityId),
213
+ description: description || '',
214
+ },
215
+ { bodyType: 'form' }
216
+ );
217
+ return { ticketId: issue.issueKey, url: `https://${domain}/view/${issue.issueKey}`, raw: issue };
218
+ }
219
+
151
220
  function jiraAuth(email, token) {
152
221
  return `Basic ${Buffer.from(`${email}:${token}`).toString('base64')}`;
153
222
  }
@@ -191,6 +260,32 @@ async function fetchJiraComment(domain, email, token, ticketId, commentId) {
191
260
  };
192
261
  }
193
262
 
263
+ // ── Jira WRITE — issue creation (PM Workflow_v1.0.md "Vấn đề 5") ────────────
264
+
265
+ /**
266
+ * Creates a Jira issue. `apiTokenWrite`/`emailWrite` are distinct from the read-only
267
+ * credentials used elsewhere in this file — same "separate WRITE key" policy as Backlog.
268
+ */
269
+ async function createJiraIssue(domain, emailWrite, apiTokenWrite, { projectKey, summary, description, issueTypeName }) {
270
+ const issue = await httpsPost(
271
+ `https://${domain}/rest/api/3/issue`,
272
+ {
273
+ fields: {
274
+ project: { key: projectKey },
275
+ summary,
276
+ issuetype: { name: issueTypeName || 'Task' },
277
+ description: {
278
+ type: 'doc',
279
+ version: 1,
280
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: description || '' }] }],
281
+ },
282
+ },
283
+ },
284
+ { headers: { Authorization: jiraAuth(emailWrite, apiTokenWrite), Accept: 'application/json' }, bodyType: 'json' }
285
+ );
286
+ return { ticketId: issue.key, url: `https://${domain}/browse/${issue.key}`, raw: issue };
287
+ }
288
+
194
289
  function linkDedupeKey(classified) {
195
290
  if (classified.kind === 'document') return 'doc:' + classified.documentId + (classified.commentId ? '#' + classified.commentId : '');
196
291
  if (classified.kind === 'wiki') return 'wiki:' + classified.wikiId;
@@ -270,4 +365,6 @@ module.exports = {
270
365
  fetchBacklogTicket, fetchBacklogComment,
271
366
  fetchBacklogDocument, fetchBacklogDocumentComment, fetchBacklogWiki,
272
367
  fetchJiraTicket, fetchJiraComment,
368
+ fetchBacklogIssueTypes, fetchBacklogPriorities, createBacklogIssue,
369
+ createJiraIssue,
273
370
  };
package/scripts/task.js CHANGED
@@ -345,8 +345,9 @@ async function nextGate(taskId) {
345
345
  const isGenDoc = taskType === 'gen-doc';
346
346
  const isSystemRequirement = taskType === 'create-system-requirement';
347
347
  const isIngestData = taskType === 'ingest-data';
348
- const isTwoGateDoc = isGenDoc || isSystemRequirement || isIngestData;
349
- const maxGate = isTwoGateDoc ? 2 : 5;
348
+ const isTwoGateDoc = isGenDoc || isSystemRequirement;
349
+ const isDocFlow = isTwoGateDoc || isIngestData;
350
+ const maxGate = isTwoGateDoc ? 2 : isIngestData ? 3 : 5;
350
351
 
351
352
  const taskState = {
352
353
  ...existing,
@@ -371,7 +372,7 @@ async function nextGate(taskId) {
371
372
  console.log(chalk.green(`✓ Gate ${currentGate} approved for ${resolvedId}.`));
372
373
  console.log(chalk.gray(` Summary saved to: .aiflow/tasks/${resolvedId}/task-summary.md`));
373
374
 
374
- if (isTwoGateDoc && currentGate >= maxGate) {
375
+ if (isDocFlow && currentGate >= maxGate) {
375
376
  console.log(chalk.green('\n ✅ Document workflow complete. Task is done.'));
376
377
  } else {
377
378
  const nextLabel = gateLabel(nextGateNum, taskType);
@@ -380,7 +381,7 @@ async function nextGate(taskId) {
380
381
  console.log(chalk.gray(` 1. Open a NEW chatbox or terminal session.`));
381
382
  console.log(chalk.gray(` 2. Run: aiflow task resume ${resolvedId} (to load context).`));
382
383
  console.log(chalk.gray(` 3. Type "start" or "continue from the current plan".`));
383
- if (!isTwoGateDoc) {
384
+ if (!isDocFlow) {
384
385
  console.log(chalk.yellow(` (Note: Gate 3 progress is saved via [x] checkboxes in the Gate 2 plan doc (AK-Docs/04.Coding/02.Plans/).`));
385
386
  console.log(chalk.yellow(` The AI will automatically resume the exact task you left off.)`));
386
387
  }
@@ -485,7 +486,10 @@ async function detectCurrentGate(taskId) {
485
486
 
486
487
  async function generateMarkdownSummary(taskState, taskType = null) {
487
488
  taskType = taskType || taskState.taskType || 'feature';
488
- const maxGate = taskType === 'testing' ? 4 : ((taskType === 'gen-doc' || taskType === 'create-system-requirement' || taskType === 'ingest-data') ? 2 : 5);
489
+ const maxGate = taskType === 'testing' ? 4
490
+ : taskType === 'ingest-data' ? 3
491
+ : (taskType === 'gen-doc' || taskType === 'create-system-requirement') ? 2
492
+ : 5;
489
493
  const lines = [];
490
494
  lines.push(`# Task Summary: ${taskState.taskId}`);
491
495
  lines.push(`**Title:** ${taskState.title}`);
@@ -531,7 +535,8 @@ function gateLabel(n, taskType = null) {
531
535
  if (taskType === 'ingest-data') {
532
536
  if (n === 1) return 'Fetch, Classify, Draft';
533
537
  if (n === 2) return 'Branch + Merge Request';
534
- if (n >= 3) return 'Done';
538
+ if (n === 3) return 'Sinh Task & Tạo Ticket';
539
+ if (n >= 4) return 'Done';
535
540
  }
536
541
  const labels = {
537
542
  1: 'AI Analyze Requirement',
@@ -599,15 +604,19 @@ module.exports.createOrActivateTaskState = async function createOrActivateTaskSt
599
604
  };
600
605
 
601
606
  /**
602
- * Called by `aiflow gate <N> <start|approved>` to keep task-state.json in sync
607
+ * Called by `aiflow gate <N> <start|approved|skip>` to keep task-state.json in sync
603
608
  * with the actual gate progress — without requiring the user to run `task next`.
604
609
  *
605
610
  * - 'start' : advances currentGate to N (if N is higher than stored value)
606
611
  * - 'approved': records approval timestamp + advances currentGate to N+1
612
+ * - 'skip' : records a skip (gate legitimately produced no deliverable, e.g.
613
+ * ingest-data Gate 3 deciding no task is needed) + advances currentGate
614
+ * to N+1, same as 'approved' — every "is this task done" check only ever
615
+ * inspects `currentGate`, so a skipped gate still lets the task complete.
607
616
  *
608
617
  * Silently no-ops when task-state.json does not exist yet (task not saved).
609
618
  */
610
- module.exports.updateTaskGateState = async function updateTaskGateState(ticketId, gateNum, action) {
619
+ module.exports.updateTaskGateState = async function updateTaskGateState(ticketId, gateNum, action, options = {}) {
611
620
  if (!ticketId) return;
612
621
  const taskDir = path.join(TASKS_DIR, ticketId);
613
622
  const statePath = path.join(taskDir, 'task-state.json');
@@ -633,6 +642,14 @@ module.exports.updateTaskGateState = async function updateTaskGateState(ticketId
633
642
  if (gateNum >= (existing.currentGate || 1)) {
634
643
  updated.currentGate = gateNum + 1;
635
644
  }
645
+ } else if (action === 'skip') {
646
+ const skippedGates = { ...(existing.skippedGates || {}) };
647
+ skippedGates[String(gateNum)] = { at: now, reason: options.reason || '' };
648
+ updated.skippedGates = skippedGates;
649
+ // Advance currentGate to next gate (only if not already ahead) — same as 'approved'
650
+ if (gateNum >= (existing.currentGate || 1)) {
651
+ updated.currentGate = gateNum + 1;
652
+ }
636
653
  }
637
654
 
638
655
  await fs.writeJson(statePath, updated, { spaces: 2 });
@@ -0,0 +1,247 @@
1
+ 'use strict';
2
+ // Vấn đề 5 (docs/internal/PM Workflow_v1.0.md) — turns an AI-proposed, PM-approved task list
3
+ // (born out of `ingest-data` Gate 3) into real tickets on Backlog/Jira.
4
+ //
5
+ // Read access (fetch-links, listing projects/issueTypes/priorities) reuses the existing
6
+ // read-only credentials already configured for `ak use`/`ak fetch-links`. Creating a ticket is a
7
+ // WRITE action and requires a SEPARATE credential (`BACKLOG_API_KEY_WRITE` / `JIRA_API_TOKEN_WRITE`)
8
+ // — same "distinct WRITE key" policy the PM Workflow doc calls for, kept apart from the read key so
9
+ // a compromised/misconfigured read key can never create tickets.
10
+
11
+ const fs = require('fs-extra');
12
+ const chalk = require('chalk');
13
+ const useCommand = require('./use');
14
+ const linkResolver = require('./link-resolver');
15
+
16
+ const {
17
+ loadCredentials,
18
+ resolveBacklogCredentials,
19
+ resolveJiraCredentials,
20
+ saveMcpCredentials,
21
+ } = useCommand;
22
+
23
+ const {
24
+ fetchBacklogIssueTypes,
25
+ fetchBacklogPriorities,
26
+ createBacklogIssue,
27
+ createJiraIssue,
28
+ } = linkResolver;
29
+
30
+ // Only these keys can be written via `ak credentials set` — dedicated adapter credentials
31
+ // (BACKLOG_API_KEY, JIRA_API_TOKEN, ...) still go through `ak init --adapter` on purpose.
32
+ const SETTABLE_CREDENTIAL_KEYS = [
33
+ 'BACKLOG_API_KEY_WRITE',
34
+ 'JIRA_API_TOKEN_WRITE',
35
+ 'JIRA_EMAIL_WRITE',
36
+ 'BACKLOG_DEFAULT_PROJECT_ID',
37
+ 'BACKLOG_DEFAULT_PROJECT_KEY',
38
+ 'JIRA_DEFAULT_PROJECT_KEY',
39
+ ];
40
+
41
+ function jiraFullDomain(domain) {
42
+ return domain.includes('.') ? domain : `${domain}.atlassian.net`;
43
+ }
44
+
45
+ function missingCredResult(target, field, message) {
46
+ return { ok: false, error: 'missing-write-credentials', target, field, message };
47
+ }
48
+
49
+ async function resolveBacklogWrite(creds) {
50
+ const read = resolveBacklogCredentials(creds);
51
+ if (!read) {
52
+ return missingCredResult(
53
+ 'backlog', 'BACKLOG_API_KEY / BACKLOG_SPACE_KEY',
54
+ 'Chưa cấu hình credentials đọc Backlog. Chạy `ak init --adapter backlog` trước.'
55
+ );
56
+ }
57
+ const apiKeyWrite = process.env.BACKLOG_API_KEY_WRITE || creds.BACKLOG_API_KEY_WRITE;
58
+ if (!apiKeyWrite) {
59
+ return missingCredResult(
60
+ 'backlog', 'BACKLOG_API_KEY_WRITE',
61
+ 'Cần API key Backlog có quyền tạo issue (khác key đọc hiện tại). Cung cấp API key này, mình sẽ lưu lại và tạo ticket ngay.'
62
+ );
63
+ }
64
+ return { ok: true, domain: read.domain, apiKeyRead: read.apiKey, apiKeyWrite };
65
+ }
66
+
67
+ async function resolveJiraWrite(creds) {
68
+ const read = resolveJiraCredentials(creds);
69
+ if (!read) {
70
+ return missingCredResult(
71
+ 'jira', 'JIRA_API_TOKEN / JIRA_EMAIL / JIRA_DOMAIN',
72
+ 'Chưa cấu hình credentials đọc Jira. Chạy `ak init --adapter jira` trước.'
73
+ );
74
+ }
75
+ const apiTokenWrite = process.env.JIRA_API_TOKEN_WRITE || creds.JIRA_API_TOKEN_WRITE;
76
+ if (!apiTokenWrite) {
77
+ return missingCredResult(
78
+ 'jira', 'JIRA_API_TOKEN_WRITE',
79
+ 'Cần Jira API token có quyền tạo issue (khác token đọc hiện tại). Cung cấp token này, mình sẽ lưu lại và tạo ticket ngay.'
80
+ );
81
+ }
82
+ const emailWrite = process.env.JIRA_EMAIL_WRITE || creds.JIRA_EMAIL_WRITE || read.email;
83
+ return { ok: true, domain: jiraFullDomain(read.domain), emailWrite, apiTokenWrite };
84
+ }
85
+
86
+ async function pickBacklogDefaults(domain, apiKeyRead, projectId) {
87
+ const [issueTypes, priorities] = await Promise.all([
88
+ fetchBacklogIssueTypes(domain, apiKeyRead, projectId),
89
+ fetchBacklogPriorities(domain, apiKeyRead),
90
+ ]);
91
+ if (!issueTypes || !issueTypes.length) throw new Error(`Project ${projectId} không có issueType nào — không thể tạo issue.`);
92
+ const defaultIssueTypeId = issueTypes[0].id;
93
+ const normal = (priorities || []).find((p) => /normal|trung bình/i.test(p.name));
94
+ const defaultPriorityId = (normal || (priorities || [])[0] || {}).id;
95
+ if (!defaultPriorityId) throw new Error('Không lấy được danh sách priority từ Backlog.');
96
+ return { defaultIssueTypeId, defaultPriorityId };
97
+ }
98
+
99
+ /**
100
+ * `ak tasks create-tickets <file.json> [--json]`
101
+ *
102
+ * Input file shape:
103
+ * {
104
+ * "target": "backlog" | "jira",
105
+ * "projectId": "12345", // optional — Backlog only, overrides BACKLOG_DEFAULT_PROJECT_ID
106
+ * "projectKey": "PROJ", // optional — Jira only, overrides JIRA_DEFAULT_PROJECT_KEY
107
+ * "tasks": [
108
+ * { "type": "spec|coding|test|other", "title": "...", "description": "..." }
109
+ * ]
110
+ * }
111
+ *
112
+ * `description` is expected to already be fully composed by the AI per the ticket template in
113
+ * PM Workflow_v1.0.md §6.1 (Nội dung task + Nguồn tham chiếu) — this command posts it as-is.
114
+ */
115
+ async function createTicketsCommand(inputFile, options = {}) {
116
+ const emit = (obj) => {
117
+ if (options.json) { process.stdout.write(JSON.stringify(obj)); return; }
118
+ if (obj.ok === false) {
119
+ console.log(chalk.red(`✗ ${obj.message || obj.error}`));
120
+ } else {
121
+ console.log(chalk.green(`✓ ${obj.results.filter(r => r.ok).length}/${obj.results.length} ticket(s) created on ${obj.target}.`));
122
+ for (const r of obj.results) {
123
+ console.log(r.ok ? ` ${chalk.green('✓')} ${r.ticketId} ${chalk.gray(r.title)}` : ` ${chalk.red('✗')} ${chalk.gray(r.title)} — ${r.error}`);
124
+ }
125
+ }
126
+ console.log(JSON.stringify(obj));
127
+ };
128
+
129
+ let input;
130
+ try {
131
+ input = await fs.readJson(inputFile);
132
+ } catch (err) {
133
+ emit({ ok: false, error: 'invalid-input-file', message: `Không đọc được ${inputFile}: ${err.message}` });
134
+ process.exitCode = 1;
135
+ return;
136
+ }
137
+
138
+ const target = input.target;
139
+ const tasks = input.tasks || [];
140
+ if (!['backlog', 'jira'].includes(target)) {
141
+ emit({ ok: false, error: 'invalid-target', message: `target phải là "backlog" hoặc "jira", nhận được: ${target}` });
142
+ process.exitCode = 1;
143
+ return;
144
+ }
145
+ if (!tasks.length) {
146
+ emit({ ok: false, error: 'empty-tasks', message: 'File input không có task nào trong "tasks".' });
147
+ process.exitCode = 1;
148
+ return;
149
+ }
150
+
151
+ const creds = await loadCredentials();
152
+
153
+ if (target === 'backlog') {
154
+ const write = await resolveBacklogWrite(creds);
155
+ if (!write.ok) { emit(write); process.exitCode = 2; return; }
156
+
157
+ const projectId = input.projectId || creds.BACKLOG_DEFAULT_PROJECT_ID;
158
+ if (!projectId) {
159
+ emit({
160
+ ok: false, error: 'missing-project',
161
+ message: 'Chưa xác định projectId đích trên Backlog. Chạy `ak backlog-projects` để xem danh sách, rồi `ak backlog-set-default-project <id> <key>`.',
162
+ });
163
+ process.exitCode = 2;
164
+ return;
165
+ }
166
+
167
+ let defaults;
168
+ try {
169
+ defaults = await pickBacklogDefaults(write.domain, write.apiKeyRead, projectId);
170
+ } catch (err) {
171
+ emit({ ok: false, error: 'project-lookup-failed', message: err.message });
172
+ process.exitCode = 1;
173
+ return;
174
+ }
175
+
176
+ const results = [];
177
+ for (const task of tasks) {
178
+ try {
179
+ const created = await createBacklogIssue(write.domain, write.apiKeyWrite, {
180
+ projectId,
181
+ summary: task.title,
182
+ description: task.description,
183
+ issueTypeId: task.issueTypeId || defaults.defaultIssueTypeId,
184
+ priorityId: task.priorityId || defaults.defaultPriorityId,
185
+ });
186
+ results.push({ type: task.type, title: task.title, ok: true, ticketId: created.ticketId, url: created.url });
187
+ } catch (err) {
188
+ results.push({ type: task.type, title: task.title, ok: false, error: err.message });
189
+ }
190
+ }
191
+ emit({ ok: true, target, projectId, results });
192
+ return;
193
+ }
194
+
195
+ // target === 'jira'
196
+ const write = await resolveJiraWrite(creds);
197
+ if (!write.ok) { emit(write); process.exitCode = 2; return; }
198
+
199
+ const projectKey = input.projectKey || creds.JIRA_DEFAULT_PROJECT_KEY;
200
+ if (!projectKey) {
201
+ emit({
202
+ ok: false, error: 'missing-project',
203
+ message: 'Chưa xác định project đích trên Jira. Chạy `ak jira-projects` để xem danh sách, rồi `ak jira-set-default-project <key>`.',
204
+ });
205
+ process.exitCode = 2;
206
+ return;
207
+ }
208
+
209
+ const results = [];
210
+ for (const task of tasks) {
211
+ try {
212
+ const created = await createJiraIssue(write.domain, write.emailWrite, write.apiTokenWrite, {
213
+ projectKey,
214
+ summary: task.title,
215
+ description: task.description,
216
+ issueTypeName: task.issueType || 'Task',
217
+ });
218
+ results.push({ type: task.type, title: task.title, ok: true, ticketId: created.ticketId, url: created.url });
219
+ } catch (err) {
220
+ results.push({ type: task.type, title: task.title, ok: false, error: err.message });
221
+ }
222
+ }
223
+ emit({ ok: true, target, projectKey, results });
224
+ }
225
+
226
+ /**
227
+ * `ak credentials set <KEY> <VALUE>` — lets the AI persist a WRITE key the user pasted in chat
228
+ * (after being asked, per the missing-write-credentials error above) without a bespoke command
229
+ * per key. Restricted to an allowlist — dedicated adapter read-credentials still go through
230
+ * `ak init --adapter`, this is only for the pieces that flow introduced.
231
+ */
232
+ async function setCredentialCommand(key, value) {
233
+ if (!SETTABLE_CREDENTIAL_KEYS.includes(key)) {
234
+ console.log(chalk.red(`✗ Unknown credential key: ${key}.`));
235
+ console.log(chalk.gray(` Allowed: ${SETTABLE_CREDENTIAL_KEYS.join(', ')}`));
236
+ process.exitCode = 1;
237
+ return;
238
+ }
239
+ await saveMcpCredentials({ [key]: value });
240
+ console.log(chalk.green(`✓ Saved ${key} to ~/.aiflow/credentials.json`));
241
+ }
242
+
243
+ module.exports = {
244
+ createTicketsCommand,
245
+ setCredentialCommand,
246
+ SETTABLE_CREDENTIAL_KEYS,
247
+ };
package/scripts/use.js CHANGED
@@ -360,6 +360,14 @@ function backlogGet(url) {
360
360
  });
361
361
  }
362
362
 
363
+ /**
364
+ * Fetch the list of projects in the Backlog space — used to let the user pick a default
365
+ * project for tasks created manually (no ticket to derive a project from).
366
+ */
367
+ function fetchBacklogProjects(domain, apiKey) {
368
+ return backlogGet(`https://${domain}/api/v2/projects?apiKey=${apiKey}`);
369
+ }
370
+
363
371
  /**
364
372
  * Fetch issue detail from Backlog REST API
365
373
  */
@@ -584,6 +592,64 @@ function fetchJiraIssue(domain, email, apiToken, issueKey) {
584
592
  });
585
593
  }
586
594
 
595
+ /**
596
+ * `ak jira-projects [--json]` — lists the projects visible to the configured Jira account, so the
597
+ * user can pick a default project for tasks created from Meeting Minutes/QnA (see Vấn đề 5.1 in
598
+ * PM Workflow_v1.0.md). Mirrors `listBacklogProjectsCommand` above.
599
+ */
600
+ function fetchJiraProjects(domain, email, apiToken) {
601
+ return new Promise((resolve, reject) => {
602
+ const auth = Buffer.from(`${email}:${apiToken}`).toString("base64");
603
+ const url = `https://${domain}.atlassian.net/rest/api/3/project`;
604
+ https
605
+ .get(url, { headers: { Authorization: `Basic ${auth}`, Accept: "application/json" } }, (res) => {
606
+ let data = "";
607
+ res.on("data", (chunk) => (data += chunk));
608
+ res.on("end", () => {
609
+ if (res.statusCode !== 200) {
610
+ reject(new Error(`HTTP ${res.statusCode}: ${data}`));
611
+ return;
612
+ }
613
+ try { resolve(JSON.parse(data)); }
614
+ catch (e) { reject(new Error("Invalid JSON response from Jira")); }
615
+ });
616
+ })
617
+ .on("error", reject);
618
+ });
619
+ }
620
+
621
+ async function listJiraProjectsCommand(options = {}) {
622
+ const creds = await loadCredentials();
623
+ const resolved = resolveJiraCredentials(creds);
624
+ if (!resolved) {
625
+ const msg = "Jira credentials not set. Run `aiflow init --adapter jira` first.";
626
+ if (options.json) { process.stdout.write(JSON.stringify({ ok: false, error: msg })); return; }
627
+ console.log(chalk.yellow(`⚠ ${msg}`));
628
+ return;
629
+ }
630
+ try {
631
+ const projects = await fetchJiraProjects(resolved.domain, resolved.email, resolved.apiToken);
632
+ const list = projects.map((p) => ({ id: p.id, key: p.key, name: p.name }));
633
+ if (options.json) {
634
+ process.stdout.write(JSON.stringify({ ok: true, projects: list, defaultProjectKey: creds.JIRA_DEFAULT_PROJECT_KEY ?? null }));
635
+ return;
636
+ }
637
+ console.log(chalk.cyan(`\nJira projects (${list.length}):`));
638
+ for (const p of list) {
639
+ const isDefault = p.key === creds.JIRA_DEFAULT_PROJECT_KEY;
640
+ console.log(` ${isDefault ? chalk.green("●") : " "} ${p.key} ${chalk.gray(p.name)} ${chalk.gray(`(id: ${p.id})`)}`);
641
+ }
642
+ } catch (err) {
643
+ if (options.json) { process.stdout.write(JSON.stringify({ ok: false, error: err.message })); return; }
644
+ console.log(chalk.red(`✗ Could not fetch Jira projects: ${err.message}`));
645
+ }
646
+ }
647
+
648
+ async function setDefaultJiraProjectCommand(key) {
649
+ await saveMcpCredentials({ JIRA_DEFAULT_PROJECT_KEY: key });
650
+ console.log(chalk.green(`✓ Default Jira project set to ${key}.`));
651
+ }
652
+
587
653
  /**
588
654
  * Fetch ALL comments of an issue from Jira REST API.
589
655
  */
@@ -787,11 +853,20 @@ async function manualContext(prefillId = "", options = {}) {
787
853
  // Task type — pre-select existing value if available
788
854
  const taskType = await promptForTaskType(existing.taskType, options.taskType);
789
855
 
856
+ // Stamp the default Backlog project (`ak backlog-set-default-project`), if one was ever set —
857
+ // manual tasks have no ticket to derive a project from otherwise. Kept for later use: syncing a
858
+ // locally-created task up to Backlog as a new issue needs to know which project it belongs to.
859
+ const creds = await loadCredentials();
860
+ const projectId = existing.projectId ?? creds.BACKLOG_DEFAULT_PROJECT_ID ?? null;
861
+ const projectKey = existing.projectKey ?? creds.BACKLOG_DEFAULT_PROJECT_KEY ?? null;
862
+
790
863
  const context = {
791
864
  taskId,
792
865
  taskType,
793
866
  title,
794
867
  description,
868
+ projectId,
869
+ projectKey,
795
870
  status: existing.status || "In Progress",
796
871
  mode: existing.mode || "auto",
797
872
  acceptanceCriteria: existing.acceptanceCriteria || [],
@@ -967,6 +1042,63 @@ async function loadCredentials() {
967
1042
  return {};
968
1043
  }
969
1044
 
1045
+ /** Always the global file (`~/.aiflow/credentials.json`) — same path `loadCredentials` reads
1046
+ * first — so a value written here is picked up by every later `loadCredentials()` call. */
1047
+ function globalCredentialsPath() {
1048
+ return path.join(os.homedir(), ".aiflow", "credentials.json");
1049
+ }
1050
+
1051
+ /** Merges `patch` into the saved `.mcp` credentials block, creating the file if needed. */
1052
+ async function saveMcpCredentials(patch) {
1053
+ const file = globalCredentialsPath();
1054
+ await fs.ensureDir(path.dirname(file));
1055
+ const data = (await fs.pathExists(file)) ? await fs.readJson(file).catch(() => ({})) : {};
1056
+ data.mcp = { ...(data.mcp || {}), ...patch };
1057
+ await fs.writeJson(file, data, { spaces: 2 });
1058
+ }
1059
+
1060
+ /**
1061
+ * `ak backlog-projects [--json]` — lists the projects in the configured Backlog space, so the
1062
+ * user (or a headless caller like the VS Code extension) can pick one as the default project for
1063
+ * manually-created tasks (see `manualContext` below, which stamps that default onto new tasks).
1064
+ */
1065
+ async function listBacklogProjectsCommand(options = {}) {
1066
+ const creds = await loadCredentials();
1067
+ const resolved = resolveBacklogCredentials(creds);
1068
+ if (!resolved) {
1069
+ const msg = "Backlog credentials not set. Run `aiflow init --adapter backlog` first.";
1070
+ if (options.json) { process.stdout.write(JSON.stringify({ ok: false, error: msg })); return; }
1071
+ console.log(chalk.yellow(`⚠ ${msg}`));
1072
+ return;
1073
+ }
1074
+ try {
1075
+ const projects = await fetchBacklogProjects(resolved.domain, resolved.apiKey);
1076
+ const list = projects.map((p) => ({ id: p.id, projectKey: p.projectKey, name: p.name }));
1077
+ if (options.json) {
1078
+ process.stdout.write(JSON.stringify({ ok: true, projects: list, defaultProjectId: creds.BACKLOG_DEFAULT_PROJECT_ID ?? null }));
1079
+ return;
1080
+ }
1081
+ console.log(chalk.cyan(`\nBacklog projects (${list.length}):`));
1082
+ for (const p of list) {
1083
+ const isDefault = String(p.id) === String(creds.BACKLOG_DEFAULT_PROJECT_ID);
1084
+ console.log(` ${isDefault ? chalk.green("●") : " "} ${p.projectKey} ${chalk.gray(p.name)} ${chalk.gray(`(id: ${p.id})`)}`);
1085
+ }
1086
+ } catch (err) {
1087
+ if (options.json) { process.stdout.write(JSON.stringify({ ok: false, error: err.message })); return; }
1088
+ console.log(chalk.red(`✗ Could not fetch Backlog projects: ${err.message}`));
1089
+ }
1090
+ }
1091
+
1092
+ /**
1093
+ * `ak backlog-set-default-project <id> <key>` — saves the chosen default project, so future
1094
+ * manually-created tasks (`ak use --manual`) get stamped with it automatically. Non-interactive —
1095
+ * safe to spawn without a TTY (see New-ex-architecture_v1.0.md §1.1 for why that distinction matters).
1096
+ */
1097
+ async function setDefaultBacklogProjectCommand(id, key) {
1098
+ await saveMcpCredentials({ BACKLOG_DEFAULT_PROJECT_ID: id, BACKLOG_DEFAULT_PROJECT_KEY: key });
1099
+ console.log(chalk.green(`✓ Default Backlog project set to ${key} (id: ${id}).`));
1100
+ }
1101
+
970
1102
  // ──────────────────────────────────────────────────────────────
971
1103
  // Context persistence
972
1104
  // ──────────────────────────────────────────────────────────────
@@ -1353,3 +1485,12 @@ module.exports.detectTaskTypeFromString = detectTaskTypeFromString;
1353
1485
  module.exports.manualContext = manualContext;
1354
1486
  module.exports.promptForTaskType = promptForTaskType;
1355
1487
  module.exports.previewTarget = previewTarget;
1488
+ module.exports.resolveBacklogCredentials = resolveBacklogCredentials;
1489
+ module.exports.fetchBacklogProjects = fetchBacklogProjects;
1490
+ module.exports.listBacklogProjectsCommand = listBacklogProjectsCommand;
1491
+ module.exports.setDefaultBacklogProjectCommand = setDefaultBacklogProjectCommand;
1492
+ module.exports.resolveJiraCredentials = resolveJiraCredentials;
1493
+ module.exports.fetchJiraProjects = fetchJiraProjects;
1494
+ module.exports.listJiraProjectsCommand = listJiraProjectsCommand;
1495
+ module.exports.setDefaultJiraProjectCommand = setDefaultJiraProjectCommand;
1496
+ module.exports.saveMcpCredentials = saveMcpCredentials;