@relipa/ai-flow-kit 0.1.9 → 0.2.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/scripts/use.js CHANGED
@@ -8,11 +8,13 @@ const { select, input } = require("@inquirer/prompts");
8
8
  const stringWidth = require("string-width");
9
9
  const CATEGORY_COLOR = {
10
10
  Coding: chalk.cyan,
11
+ Dev: chalk.blueBright,
11
12
  Testing: chalk.green,
12
13
  Analysis: chalk.yellow,
13
14
  Document: chalk.magenta,
14
15
  BA: chalk.blue,
15
16
  QA: chalk.greenBright,
17
+ PM: chalk.redBright,
16
18
  };
17
19
  const td = (label, cat, desc) => {
18
20
  const pad = " ".repeat(Math.max(0, 20 - stringWidth(label)));
@@ -32,6 +34,15 @@ const useCommand = async function use(targets, options = {}) {
32
34
  // Normalize: accept string (legacy) or array (new multi-target)
33
35
  const targetList = Array.isArray(targets) ? targets.filter(Boolean) : (targets ? [targets] : []);
34
36
  const primaryTarget = targetList[0] || null;
37
+
38
+ // ── Preview mode: fetch + print JSON to stdout, nothing else — no save, no
39
+ // prompts, no other console output (a caller like a GUI parses stdout
40
+ // as JSON, so nothing may print ahead of it). Must run before every
41
+ // other check/notice below, all of which write informational stdout. ──
42
+ if (options.preview) {
43
+ return await previewTarget(primaryTarget, options);
44
+ }
45
+
35
46
  const supplementaryTargets = targetList.slice(1);
36
47
 
37
48
  if (!(await fs.pathExists(STATE_FILE))) {
@@ -56,7 +67,7 @@ const useCommand = async function use(targets, options = {}) {
56
67
 
57
68
  // ── Manual entry ──
58
69
  if (options.manual) {
59
- return await manualContext();
70
+ return await manualContext("", options);
60
71
  }
61
72
 
62
73
  // ── Load from local file ──
@@ -92,7 +103,7 @@ const useCommand = async function use(targets, options = {}) {
92
103
  console.log(
93
104
  chalk.gray("Supported: PROJ-33, APP-123, version number, --manual"),
94
105
  );
95
- return await manualContext();
106
+ return await manualContext("", options);
96
107
  };
97
108
 
98
109
  // ──────────────────────────────────────────────────────────────
@@ -192,65 +203,87 @@ async function switchVersion(versionReq) {
192
203
  // Backlog context loader
193
204
  // ──────────────────────────────────────────────────────────────
194
205
 
195
- async function loadFromBacklog(issueKey, options = {}) {
196
- const creds = await loadCredentials();
206
+ /** Resolves Backlog credentials from env/config, or null if unset. */
207
+ function resolveBacklogCredentials(creds) {
197
208
  const apiKey = process.env.BACKLOG_API_KEY || creds.BACKLOG_API_KEY;
198
209
  const spaceKey =
199
210
  process.env.BACKLOG_SPACE_KEY ||
200
211
  creds.BACKLOG_SPACE_KEY ||
201
212
  process.env.BACKLOG_DOMAIN ||
202
213
  creds.BACKLOG_DOMAIN;
214
+ if (!apiKey || !spaceKey) return null;
215
+ // spaceKey can be full domain (mycompany.backlog.com) or just the space (mycompany)
216
+ const domain = spaceKey.includes(".") ? spaceKey : `${spaceKey}.backlog.com`;
217
+ return { apiKey, domain };
218
+ }
219
+
220
+ /**
221
+ * Pure fetch: resolves a Backlog issue into internal context format.
222
+ * Throws on missing credentials or fetch failure — never prompts, never saves.
223
+ * Used by both `loadFromBacklog` (interactive) and `previewTarget` (headless).
224
+ */
225
+ async function fetchBacklogContext(issueKey, options = {}) {
226
+ const creds = await loadCredentials();
227
+ const resolved = resolveBacklogCredentials(creds);
228
+ if (!resolved) {
229
+ throw new Error(
230
+ "Backlog credentials not set. Run `aiflow init --adapter backlog` or set BACKLOG_API_KEY / BACKLOG_SPACE_KEY.",
231
+ );
232
+ }
233
+ const { apiKey, domain } = resolved;
234
+
235
+ // Default: description only. Comments loaded only when explicitly requested.
236
+ const loadComments =
237
+ options.coms ||
238
+ options.withComs ||
239
+ options.withComments ||
240
+ options["with-comments"] ||
241
+ options.with_comments ||
242
+ options.cid != null ||
243
+ options.commentId != null ||
244
+ options["comment-id"] != null ||
245
+ options.clast != null ||
246
+ options.commentsLast != null ||
247
+ options.cfrom != null ||
248
+ options.commentsFrom != null ||
249
+ options.cto != null ||
250
+ options.commentsTo != null ||
251
+ options["comments-to"] != null;
252
+
253
+ if (loadComments) {
254
+ console.log(chalk.gray(" ℹ Comments requested..."));
255
+ }
256
+ const [issue, rawComments] = await Promise.all([
257
+ fetchBacklogIssue(domain, apiKey, issueKey),
258
+ loadComments
259
+ ? fetchBacklogComments(domain, apiKey, issueKey)
260
+ : Promise.resolve([]),
261
+ ]);
262
+
263
+ const comments = filterComments(rawComments, options);
264
+ if (loadComments) {
265
+ console.log(chalk.gray(` ℹ Comments: ${rawComments.length} fetched, ${comments.length} kept after filtering.`));
266
+ }
267
+ return buildContextFromBacklog(issue, comments, issueKey, domain);
268
+ }
203
269
 
204
- if (!apiKey || !spaceKey) {
270
+ async function loadFromBacklog(issueKey, options = {}) {
271
+ const creds = await loadCredentials();
272
+ if (!resolveBacklogCredentials(creds)) {
205
273
  console.log(chalk.yellow("⚠ Backlog credentials not set."));
206
274
  console.log(chalk.gray("Run: aiflow init --adapter backlog"));
207
275
  console.log(chalk.gray("Or set environment variables:"));
208
276
  console.log(chalk.gray(" BACKLOG_API_KEY=your-api-key"));
209
277
  console.log(chalk.gray(" BACKLOG_SPACE_KEY=your-space (e.g. mycompany)"));
210
278
  console.log(chalk.gray("\nFalling back to manual entry...\n"));
211
- return await manualContext(issueKey);
279
+ return await manualContext(issueKey, options);
212
280
  }
213
281
 
214
- // spaceKey can be full domain (mycompany.backlog.com) or just the space (mycompany)
215
- const domain = spaceKey.includes(".") ? spaceKey : `${spaceKey}.backlog.com`;
216
-
217
282
  console.log(chalk.blue(`Fetching context from Backlog: ${issueKey}...`));
218
283
 
219
284
  try {
220
- // Default: description only. Comments loaded only when explicitly requested.
221
- const loadComments =
222
- options.coms ||
223
- options.withComs ||
224
- options.withComments ||
225
- options["with-comments"] ||
226
- options.with_comments ||
227
- options.cid != null ||
228
- options.commentId != null ||
229
- options["comment-id"] != null ||
230
- options.clast != null ||
231
- options.commentsLast != null ||
232
- options.cfrom != null ||
233
- options.commentsFrom != null ||
234
- options.cto != null ||
235
- options.commentsTo != null ||
236
- options["comments-to"] != null;
237
-
238
- if (loadComments) {
239
- console.log(chalk.gray(" ℹ Comments requested..."));
240
- }
241
- const [issue, rawComments] = await Promise.all([
242
- fetchBacklogIssue(domain, apiKey, issueKey),
243
- loadComments
244
- ? fetchBacklogComments(domain, apiKey, issueKey)
245
- : Promise.resolve([]),
246
- ]);
247
-
248
- const comments = filterComments(rawComments, options);
249
- if (loadComments) {
250
- console.log(chalk.gray(` ℹ Comments: ${rawComments.length} fetched, ${comments.length} kept after filtering.`));
251
- }
252
- const context = buildContextFromBacklog(issue, comments, issueKey, domain);
253
- context.taskType = await promptForTaskType(context.taskType);
285
+ const context = await fetchBacklogContext(issueKey, options);
286
+ context.taskType = await promptForTaskType(context.taskType, options.taskType);
254
287
  context.mode = options.full ? "full" : "fast";
255
288
  await saveContext(context, options.save);
256
289
  printContextSummary(context);
@@ -258,7 +291,7 @@ async function loadFromBacklog(issueKey, options = {}) {
258
291
  } catch (err) {
259
292
  console.log(chalk.yellow(`⚠ Could not fetch from Backlog: ${err.message}`));
260
293
  console.log(chalk.gray("Falling back to manual entry...\n"));
261
- await manualContext(issueKey);
294
+ await manualContext(issueKey, options);
262
295
  }
263
296
  }
264
297
 
@@ -395,6 +428,8 @@ function buildContextFromBacklog(issue, comments, issueKey, domain) {
395
428
 
396
429
  return {
397
430
  taskId: issueKey,
431
+ projectId: issue.projectId ?? null,
432
+ projectKey: projectKeyFromIssueKey(issueKey),
398
433
  taskType: type,
399
434
  title: issue.summary || "",
400
435
  description: issue.description || "",
@@ -430,13 +465,67 @@ function buildContextFromBacklog(issue, comments, issueKey, domain) {
430
465
  // Jira context loader
431
466
  // ──────────────────────────────────────────────────────────────
432
467
 
433
- async function loadFromJira(issueKey, options = {}) {
434
- const creds = await loadCredentials();
468
+ /** Resolves Jira credentials from env/config, or null if unset. */
469
+ function resolveJiraCredentials(creds) {
435
470
  const apiToken = process.env.JIRA_API_TOKEN || creds.JIRA_API_TOKEN;
436
471
  const email = process.env.JIRA_EMAIL || creds.JIRA_EMAIL;
437
472
  const domain = process.env.JIRA_DOMAIN || creds.JIRA_DOMAIN;
473
+ if (!apiToken || !email || !domain) return null;
474
+ return { apiToken, email, domain };
475
+ }
476
+
477
+ /**
478
+ * Pure fetch: resolves a Jira issue into internal context format.
479
+ * Throws on missing credentials or fetch failure — never prompts, never saves.
480
+ * Used by both `loadFromJira` (interactive) and `previewTarget` (headless).
481
+ */
482
+ async function fetchJiraContext(issueKey, options = {}) {
483
+ const creds = await loadCredentials();
484
+ const resolved = resolveJiraCredentials(creds);
485
+ if (!resolved) {
486
+ throw new Error(
487
+ "Jira credentials not set. Set JIRA_API_TOKEN, JIRA_EMAIL, JIRA_DOMAIN.",
488
+ );
489
+ }
490
+ const { apiToken, email, domain } = resolved;
491
+
492
+ const loadComments =
493
+ options.coms ||
494
+ options.withComs ||
495
+ options.withComments ||
496
+ options["with-comments"] ||
497
+ options.with_comments ||
498
+ options.cid != null ||
499
+ options.commentId != null ||
500
+ options["comment-id"] != null ||
501
+ options.clast != null ||
502
+ options.commentsLast != null ||
503
+ options.cfrom != null ||
504
+ options.commentsFrom != null ||
505
+ options.cto != null ||
506
+ options.commentsTo != null ||
507
+ options["comments-to"] != null;
508
+
509
+ if (loadComments) {
510
+ console.log(chalk.gray(" ℹ Comments requested..."));
511
+ }
512
+ const [issue, rawComments] = await Promise.all([
513
+ fetchJiraIssue(domain, email, apiToken, issueKey),
514
+ loadComments
515
+ ? fetchJiraComments(domain, email, apiToken, issueKey)
516
+ : Promise.resolve([]),
517
+ ]);
518
+
519
+ const comments = filterComments(rawComments, options);
520
+ if (loadComments) {
521
+ console.log(chalk.gray(` ℹ Comments: ${rawComments.length} fetched, ${comments.length} kept after filtering.`));
522
+ }
523
+ return buildContextFromJira(issue, comments, issueKey, domain);
524
+ }
438
525
 
439
- if (!apiToken || !email || !domain) {
526
+ async function loadFromJira(issueKey, options = {}) {
527
+ const creds = await loadCredentials();
528
+ if (!resolveJiraCredentials(creds)) {
440
529
  console.log(chalk.yellow("⚠ Jira credentials not set."));
441
530
  console.log(chalk.gray("Run: aiflow init --adapter jira"));
442
531
  console.log(
@@ -445,45 +534,14 @@ async function loadFromJira(issueKey, options = {}) {
445
534
  ),
446
535
  );
447
536
  console.log(chalk.gray("\nFalling back to manual entry...\n"));
448
- return await manualContext(issueKey);
537
+ return await manualContext(issueKey, options);
449
538
  }
450
539
 
451
540
  console.log(chalk.blue(`Fetching context from Jira: ${issueKey}...`));
452
541
 
453
542
  try {
454
- const loadComments =
455
- options.coms ||
456
- options.withComs ||
457
- options.withComments ||
458
- options["with-comments"] ||
459
- options.with_comments ||
460
- options.cid != null ||
461
- options.commentId != null ||
462
- options["comment-id"] != null ||
463
- options.clast != null ||
464
- options.commentsLast != null ||
465
- options.cfrom != null ||
466
- options.commentsFrom != null ||
467
- options.cto != null ||
468
- options.commentsTo != null ||
469
- options["comments-to"] != null;
470
-
471
- if (loadComments) {
472
- console.log(chalk.gray(" ℹ Comments requested..."));
473
- }
474
- const [issue, rawComments] = await Promise.all([
475
- fetchJiraIssue(domain, email, apiToken, issueKey),
476
- loadComments
477
- ? fetchJiraComments(domain, email, apiToken, issueKey)
478
- : Promise.resolve([]),
479
- ]);
480
-
481
- const comments = filterComments(rawComments, options);
482
- if (loadComments) {
483
- console.log(chalk.gray(` ℹ Comments: ${rawComments.length} fetched, ${comments.length} kept after filtering.`));
484
- }
485
- const context = buildContextFromJira(issue, comments, issueKey, domain);
486
- context.taskType = await promptForTaskType(context.taskType);
543
+ const context = await fetchJiraContext(issueKey, options);
544
+ context.taskType = await promptForTaskType(context.taskType, options.taskType);
487
545
  context.mode = options.full ? "full" : "fast";
488
546
  await saveContext(context, options.save);
489
547
  printContextSummary(context);
@@ -491,7 +549,7 @@ async function loadFromJira(issueKey, options = {}) {
491
549
  } catch (err) {
492
550
  console.log(chalk.yellow(`⚠ Could not fetch from Jira: ${err.message}`));
493
551
  console.log(chalk.gray("Falling back to manual entry...\n"));
494
- await manualContext(issueKey);
552
+ await manualContext(issueKey, options);
495
553
  }
496
554
  }
497
555
 
@@ -590,6 +648,8 @@ function buildContextFromJira(issue, comments, issueKey, domain) {
590
648
 
591
649
  return {
592
650
  taskId: issueKey,
651
+ projectId: fields.project?.id ?? null,
652
+ projectKey: fields.project?.key ?? projectKeyFromIssueKey(issueKey),
593
653
  taskType: type,
594
654
  title: fields.summary || "",
595
655
  description:
@@ -623,7 +683,13 @@ function buildContextFromJira(issue, comments, issueKey, domain) {
623
683
  // Task type selector — shared by all loaders
624
684
  // ──────────────────────────────────────────────────────────────
625
685
 
626
- async function promptForTaskType(detectedDefault) {
686
+ /**
687
+ * Returns `explicit` immediately (no prompt) when the caller already knows the
688
+ * task type — e.g. `--task-type` was passed by a headless caller. Otherwise
689
+ * falls back to the interactive `select()` prompt as before.
690
+ */
691
+ async function promptForTaskType(detectedDefault, explicit) {
692
+ if (explicit != null && explicit !== "") return explicit;
627
693
  return await select({
628
694
  message: "Task type:",
629
695
  choices: [
@@ -633,10 +699,12 @@ async function promptForTaskType(detectedDefault) {
633
699
  { name: td("▶️ Execute Test", "Testing", "Run existing TC scripts (4-gate flow)"), value: "execute" },
634
700
  { name: td("📋 Create Spec", "BA", "Create UC Spec from raw requirements (4-gate BA)"), value: "create-spec" },
635
701
  { name: td("✅ Create TestCase", "QA", "Create Test Cases from requirements (4-gate QA)"), value: "create-testcase" },
702
+ { name: td("📐 Create System Requirement", "Dev", "Bridge UC Spec → System Requirement (blocks coding Gate 1, 2-gate)"), value: "create-system-requirement" },
636
703
  { name: td("🔍 Investigation", "Analysis", "Investigate, analyze root cause"), value: "investigation" },
637
704
  { name: td("📊 Impact Analysis", "Analysis", "Assess scope and risk of changes"), value: "impact-analysis" },
638
705
  { name: td("📖 Documentation", "Document", "Write docs, README, API reference"), value: "documentation" },
639
706
  { name: td("📝 Generate Doc/Spec", "Document", "Generate new documentation/Spec (2-gate)"), value: "gen-doc" },
707
+ { name: td("📥 Ingest Data", "PM", "Ingest Backlog/Jira/SharePoint link or pasted text into AK-Docs logs (2-gate)"), value: "ingest-data" },
640
708
  ],
641
709
  default: detectedDefault || "feature",
642
710
  });
@@ -646,7 +714,7 @@ async function promptForTaskType(detectedDefault) {
646
714
  // Manual context entry
647
715
  // ──────────────────────────────────────────────────────────────
648
716
 
649
- async function manualContext(prefillId = "") {
717
+ async function manualContext(prefillId = "", options = {}) {
650
718
  // Load existing context for pre-fill (Edit mode)
651
719
  let existing = {};
652
720
  const currentPath = path.join(CONTEXT_DIR, "current.json");
@@ -655,65 +723,69 @@ async function manualContext(prefillId = "") {
655
723
  }
656
724
 
657
725
  const isEdit = !!existing.taskId;
658
- if (isEdit) {
659
- console.log(chalk.cyan("\nEdit Context\n"));
660
- console.log(
661
- chalk.gray("Press Enter to keep existing value shown in [brackets]\n"),
662
- );
663
- } else {
664
- console.log(chalk.cyan("\nManual Context Entry\n"));
726
+ // Headless callers (extension popup, scripts) pass fields as flags — skip
727
+ // the corresponding inquirer prompt entirely when a field is supplied.
728
+ if (options.title == null && options.description == null && options.taskType == null) {
729
+ if (isEdit) {
730
+ console.log(chalk.cyan("\nEdit Context\n"));
731
+ console.log(
732
+ chalk.gray("Press Enter to keep existing value shown in [brackets]\n"),
733
+ );
734
+ } else {
735
+ console.log(chalk.cyan("\nManual Context Entry\n"));
736
+ }
665
737
  }
666
738
 
667
739
  // Ticket ID
668
- const defaultId = prefillId || existing.taskId || "";
669
- const idHint = defaultId ? chalk.gray(` [${defaultId}]`) : "";
670
- const idInput = await input({
671
- message: `Ticket ID (e.g. PROJ-33)${idHint}:`,
672
- default: "",
673
- });
674
- const taskId = idInput.trim() || defaultId;
740
+ const defaultId = options.ticketId || prefillId || existing.taskId || "";
741
+ let taskId;
742
+ if (options.ticketId != null) {
743
+ taskId = options.ticketId;
744
+ } else {
745
+ const idHint = defaultId ? chalk.gray(` [${defaultId}]`) : "";
746
+ const idInput = await input({
747
+ message: `Ticket ID (e.g. PROJ-33)${idHint}:`,
748
+ default: "",
749
+ });
750
+ taskId = idInput.trim() || defaultId;
751
+ }
675
752
 
676
753
  // Title
677
- const defaultTitle = existing.title || "";
678
- const titlePreview =
679
- defaultTitle.length > 50
680
- ? defaultTitle.substring(0, 50) + "…"
681
- : defaultTitle;
682
- const titleHint = defaultTitle ? chalk.gray(` [${titlePreview}]`) : "";
683
- const titleInput = await input({
684
- message: `Title${titleHint}:`,
685
- default: "",
686
- });
687
- const title = titleInput.trim() || defaultTitle;
754
+ let title;
755
+ if (options.title != null) {
756
+ title = options.title;
757
+ } else {
758
+ const defaultTitle = existing.title || "";
759
+ const titlePreview =
760
+ defaultTitle.length > 50
761
+ ? defaultTitle.substring(0, 50) + "…"
762
+ : defaultTitle;
763
+ const titleHint = defaultTitle ? chalk.gray(` [${titlePreview}]`) : "";
764
+ const titleInput = await input({
765
+ message: `Title${titleHint}:`,
766
+ default: "",
767
+ });
768
+ title = titleInput.trim() || defaultTitle;
769
+ }
688
770
 
689
771
  // Description
690
- const defaultDesc = existing.description || "";
691
- const descPreview =
692
- defaultDesc.length > 60 ? defaultDesc.substring(0, 60) + "…" : defaultDesc;
693
- const descHint = defaultDesc ? chalk.gray(` [${descPreview}]`) : "";
694
- const descInput = await input({
695
- message: `Description (brief)${descHint}:`,
696
- default: "",
697
- });
698
- const description = descInput.trim() || defaultDesc;
772
+ let description;
773
+ if (options.description != null) {
774
+ description = options.description;
775
+ } else {
776
+ const defaultDesc = existing.description || "";
777
+ const descPreview =
778
+ defaultDesc.length > 60 ? defaultDesc.substring(0, 60) + "" : defaultDesc;
779
+ const descHint = defaultDesc ? chalk.gray(` [${descPreview}]`) : "";
780
+ const descInput = await input({
781
+ message: `Description (brief)${descHint}:`,
782
+ default: "",
783
+ });
784
+ description = descInput.trim() || defaultDesc;
785
+ }
699
786
 
700
787
  // Task type — pre-select existing value if available
701
- const taskType = await select({
702
- message: "Task type:",
703
- choices: [
704
- { name: td("🐛 Bug Fix", "Coding", "Fix bugs, crashes, regressions"), value: "bug-fix" },
705
- { name: td("✨ Feature", "Coding", "Build new features, user stories"), value: "feature" },
706
- { name: td("🔄 Refactor", "Coding", "Improve code without behavior change"), value: "refactor" },
707
- { name: td("▶️ Execute Test", "Testing", "Run existing TC scripts (4-gate flow)"), value: "execute" },
708
- { name: td("📋 Create Spec", "BA", "Create UC Spec from raw requirements (4-gate BA)"), value: "create-spec" },
709
- { name: td("✅ Create TestCase", "QA", "Create Test Cases from requirements (4-gate QA)"), value: "create-testcase" },
710
- { name: td("🔍 Investigation", "Analysis", "Investigate, analyze root cause"), value: "investigation" },
711
- { name: td("📊 Impact Analysis", "Analysis", "Assess scope and risk of changes"), value: "impact-analysis" },
712
- { name: td("📖 Documentation", "Document", "Write docs, README, API reference"), value: "documentation" },
713
- { name: td("📝 Generate Doc/Spec", "Document", "Generate new documentation/Spec (2-gate)"), value: "gen-doc" },
714
- ],
715
- default: existing.taskType || undefined,
716
- });
788
+ const taskType = await promptForTaskType(existing.taskType, options.taskType);
717
789
 
718
790
  const context = {
719
791
  taskId,
@@ -765,13 +837,51 @@ async function loadFromFile(filePath, options = {}) {
765
837
  context.mode = options.full ? "full" : "fast";
766
838
 
767
839
  // Prompt for task type
768
- context.taskType = await promptForTaskType(context.taskType);
840
+ context.taskType = await promptForTaskType(context.taskType, options.taskType);
769
841
 
770
842
  await saveContext(context, options.save);
771
843
  printContextSummary(context);
772
844
  await suggestNextStep();
773
845
  }
774
846
 
847
+ /**
848
+ * Preview mode (`ak use <target> --preview`): fetches a ticket and prints its
849
+ * resolved context as JSON to stdout — no save, no prompts, no other output.
850
+ * Lets a GUI caller (e.g. the VS Code extension's popup) show fetched
851
+ * Title/Description/Project ID/Key/detected Task Type before committing.
852
+ */
853
+ async function previewTarget(target, options = {}) {
854
+ if (!target) {
855
+ process.stderr.write("ak use --preview requires a ticket ID or Backlog/Jira URL.\n");
856
+ process.exitCode = 1;
857
+ return;
858
+ }
859
+
860
+ let issueKey = target;
861
+ let adapter;
862
+ if (isTicketId(target)) {
863
+ adapter = await resolveTicketAdapter();
864
+ } else if (isBacklogUrl(target)) {
865
+ issueKey = extractBacklogId(target);
866
+ adapter = "backlog";
867
+ } else {
868
+ process.stderr.write(`ak use --preview only supports a ticket ID or Backlog URL, got: ${target}\n`);
869
+ process.exitCode = 1;
870
+ return;
871
+ }
872
+
873
+ try {
874
+ const context =
875
+ adapter === "jira"
876
+ ? await fetchJiraContext(issueKey, options)
877
+ : await fetchBacklogContext(issueKey, options);
878
+ process.stdout.write(JSON.stringify(context) + "\n");
879
+ } catch (err) {
880
+ process.stderr.write(`Error fetching preview: ${err.message}\n`);
881
+ process.exitCode = 1;
882
+ }
883
+ }
884
+
775
885
  /**
776
886
  * Build a minimal context object from arbitrary plain-text file content.
777
887
  * Tries to detect ticket ID (e.g. PROJ-33, APP-123) and a title from the first non-blank line.
@@ -939,6 +1049,8 @@ async function saveContext(context, saveName) {
939
1049
  const state = await fs.readJson(STATE_FILE);
940
1050
  state.current_context = context.taskId;
941
1051
  state.current_context_type = context.taskType;
1052
+ state.current_context_project_id = context.projectId ?? null;
1053
+ state.current_context_project_key = context.projectKey ?? null;
942
1054
  await fs.writeJson(STATE_FILE, state);
943
1055
  }
944
1056
  }
@@ -985,9 +1097,20 @@ function extractRelatedTickets(text) {
985
1097
  return [...new Set(matches)];
986
1098
  }
987
1099
 
1100
+ /** Both Backlog and Jira issue keys are PROJECTKEY-NUMBER — strip the trailing -NUMBER. */
1101
+ function projectKeyFromIssueKey(issueKey) {
1102
+ const match = String(issueKey || "").match(/^([A-Z][A-Z0-9_]+)-\d+$/);
1103
+ return match ? match[1] : null;
1104
+ }
1105
+
988
1106
  function printContextSummary(context) {
989
1107
  console.log(chalk.green("\n✓ Context loaded\n"));
990
1108
  console.log(` ${chalk.white("Ticket:")} ${context.taskId}`);
1109
+ if (context.projectKey || context.projectId != null) {
1110
+ console.log(
1111
+ ` ${chalk.white("Project:")} ${context.projectKey || "(unknown key)"}${context.projectId != null ? chalk.gray(` (id: ${context.projectId})`) : ""}`,
1112
+ );
1113
+ }
991
1114
  console.log(` ${chalk.white("Type:")} ${context.taskType}`);
992
1115
  console.log(
993
1116
  ` ${chalk.white("Title:")} ${context.title.substring(0, 70)}`,
@@ -1227,3 +1350,6 @@ async function enrichWithSupplementary(supplementaryTargets) {
1227
1350
  module.exports = useCommand;
1228
1351
  module.exports.loadCredentials = loadCredentials;
1229
1352
  module.exports.detectTaskTypeFromString = detectTaskTypeFromString;
1353
+ module.exports.manualContext = manualContext;
1354
+ module.exports.promptForTaskType = promptForTaskType;
1355
+ module.exports.previewTarget = previewTarget;