bosun 0.33.0 → 0.33.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 (49) hide show
  1. package/.env.example +6 -0
  2. package/agent-event-bus.mjs +1 -0
  3. package/agent-prompts.mjs +6 -0
  4. package/config.mjs +84 -22
  5. package/kanban-adapter.mjs +258 -3
  6. package/library-manager.mjs +727 -0
  7. package/monitor.mjs +3 -0
  8. package/package.json +13 -1
  9. package/primary-agent.mjs +46 -1
  10. package/repo-root.mjs +39 -20
  11. package/review-agent.mjs +9 -4
  12. package/session-tracker.mjs +1 -0
  13. package/setup-web-server.mjs +97 -10
  14. package/setup.mjs +31 -0
  15. package/shared-state-manager.mjs +83 -10
  16. package/task-attachments.mjs +187 -0
  17. package/task-executor.mjs +186 -4
  18. package/ui/app.js +3 -1
  19. package/ui/components/chat-view.js +132 -5
  20. package/ui/demo.html +600 -30
  21. package/ui/index.html +0 -1
  22. package/ui/modules/api.js +4 -1
  23. package/ui/modules/icon-utils.js +7 -0
  24. package/ui/modules/icons.js +13 -0
  25. package/ui/modules/router.js +3 -2
  26. package/ui/modules/streaming.js +4 -3
  27. package/ui/setup.html +22 -8
  28. package/ui/styles/components.css +119 -11
  29. package/ui/styles/layout.css +31 -0
  30. package/ui/styles/sessions.css +117 -30
  31. package/ui/styles/variables.css +1 -1
  32. package/ui/styles.css +1 -0
  33. package/ui/tabs/agents.js +14 -4
  34. package/ui/tabs/control.js +30 -3
  35. package/ui/tabs/library.js +695 -0
  36. package/ui/tabs/tasks.js +213 -0
  37. package/ui/tabs/workflows.js +27 -2
  38. package/ui-server.mjs +575 -21
  39. package/workflow-engine.mjs +822 -0
  40. package/workflow-migration.mjs +396 -0
  41. package/workflow-nodes.mjs +1811 -0
  42. package/workflow-templates/_helpers.mjs +61 -0
  43. package/workflow-templates/agents.mjs +425 -0
  44. package/workflow-templates/ci-cd.mjs +77 -0
  45. package/workflow-templates/github.mjs +432 -0
  46. package/workflow-templates/planning.mjs +266 -0
  47. package/workflow-templates/reliability.mjs +351 -0
  48. package/workflow-templates.mjs +243 -0
  49. package/workspace-manager.mjs +29 -10
package/.env.example CHANGED
@@ -435,6 +435,12 @@ TELEGRAM_MINIAPP_ENABLED=false
435
435
  # BOSUN_ENFORCE_TASK_LABEL=true
436
436
  # Optional issue fetch cap per sync/poll cycle (default: 1000)
437
437
  # GITHUB_ISSUES_LIST_LIMIT=1000
438
+ # Task context limits (comments + attachments)
439
+ # BOSUN_TASK_CONTEXT_MAX_COMMENTS=8
440
+ # BOSUN_TASK_CONTEXT_MAX_COMMENT_CHARS=1200
441
+ # BOSUN_TASK_CONTEXT_MAX_ATTACHMENTS=20
442
+ # Max upload size for task/chat attachments (MB)
443
+ # BOSUN_ATTACHMENT_MAX_MB=25
438
444
 
439
445
  # Jira backend (KANBAN_BACKEND=jira)
440
446
  # Jira Cloud site URL (no trailing slash)
@@ -785,6 +785,7 @@ export class AgentEventBus {
785
785
  branchName:
786
786
  result?.branch || task?.branchName || task?.meta?.branch_name,
787
787
  description: task?.description || "",
788
+ taskContext: task?._taskContextBlock || task?.meta?.taskContextBlock || "",
788
789
  });
789
790
  this.emit(AGENT_EVENT.AUTO_REVIEW, taskId, {
790
791
  title: task?.title || "",
package/agent-prompts.mjs CHANGED
@@ -240,6 +240,7 @@ You are the always-on reliability guardian for bosun in devmode.
240
240
 
241
241
  ## Description
242
242
  {{TASK_DESCRIPTION}}
243
+ {{TASK_CONTEXT}}
243
244
 
244
245
  ## Environment
245
246
  - Working Directory: {{WORKTREE_PATH}}
@@ -326,6 +327,7 @@ Please:
326
327
 
327
328
  Original task description:
328
329
  {{TASK_DESCRIPTION}}
330
+ {{TASK_CONTEXT}}
329
331
  `,
330
332
  taskExecutorContinueHasCommits: `# {{TASK_ID}} — CONTINUE (Verify and Push)
331
333
 
@@ -336,6 +338,7 @@ You already made commits.
336
338
  2. If passing, push: git push origin HEAD
337
339
  3. If failing, fix issues, commit, and push.
338
340
  4. Task is not complete until push succeeds.
341
+ {{TASK_CONTEXT}}
339
342
  `,
340
343
  taskExecutorContinueHasEdits: `# {{TASK_ID}} — CONTINUE (Commit and Push)
341
344
 
@@ -346,6 +349,7 @@ You made file edits but no commit yet.
346
349
  2. Run relevant tests.
347
350
  3. Commit with conventional format.
348
351
  4. Push: git push origin HEAD
352
+ {{TASK_CONTEXT}}
349
353
  `,
350
354
  taskExecutorContinueNoProgress: `# CONTINUE - Resume Implementation
351
355
 
@@ -360,6 +364,7 @@ Execute now:
360
364
 
361
365
  Task: {{TASK_TITLE}}
362
366
  Description: {{TASK_DESCRIPTION}}
367
+ {{TASK_CONTEXT}}
363
368
  `,
364
369
  reviewer: `You are a senior code reviewer for a production software project.
365
370
 
@@ -385,6 +390,7 @@ Review the following PR diff for CRITICAL issues ONLY.
385
390
 
386
391
  ## Task Description
387
392
  {{TASK_DESCRIPTION}}
393
+ {{TASK_CONTEXT}}
388
394
 
389
395
  ## Response Format
390
396
  Respond with JSON only:
package/config.mjs CHANGED
@@ -65,7 +65,8 @@ function isWslInteropRuntime() {
65
65
  }
66
66
 
67
67
  function resolveConfigDir(repoRoot) {
68
- // 1. Explicit env override
68
+ // 1. Explicit env override (BOSUN_HOME supersedes BOSUN_DIR; both are aliases)
69
+ if (process.env.BOSUN_HOME) return resolve(process.env.BOSUN_HOME);
69
70
  if (process.env.BOSUN_DIR) return resolve(process.env.BOSUN_DIR);
70
71
 
71
72
  // 2. Platform-aware user home
@@ -86,6 +87,55 @@ function resolveConfigDir(repoRoot) {
86
87
  return resolve(baseDir, "bosun");
87
88
  }
88
89
 
90
+ function getConfigSearchDirs(repoRoot) {
91
+ const dirs = new Set();
92
+ if (process.env.BOSUN_HOME) dirs.add(resolve(process.env.BOSUN_HOME));
93
+ if (process.env.BOSUN_DIR) dirs.add(resolve(process.env.BOSUN_DIR));
94
+ dirs.add(resolveConfigDir(repoRoot));
95
+ if (process.env.APPDATA) dirs.add(resolve(process.env.APPDATA, "bosun"));
96
+ if (process.env.LOCALAPPDATA) dirs.add(resolve(process.env.LOCALAPPDATA, "bosun"));
97
+ if (process.env.USERPROFILE) dirs.add(resolve(process.env.USERPROFILE, "bosun"));
98
+ if (process.env.HOME) dirs.add(resolve(process.env.HOME, "bosun"));
99
+ return [...dirs].filter(Boolean);
100
+ }
101
+
102
+ function collectRepoPathsFromConfig(cfg, configDir) {
103
+ const paths = [];
104
+ const pushPath = (path) => {
105
+ if (!path) return;
106
+ paths.push(isAbsolute(path) ? path : resolve(configDir, path));
107
+ };
108
+
109
+ const repos = cfg.repositories || cfg.repos || [];
110
+ if (Array.isArray(repos)) {
111
+ for (const repo of repos) {
112
+ const repoPath = typeof repo === "string" ? repo : (repo?.path || repo?.repoRoot);
113
+ pushPath(repoPath);
114
+ }
115
+ }
116
+
117
+ const workspaces = cfg.workspaces || [];
118
+ if (Array.isArray(workspaces)) {
119
+ for (const ws of workspaces) {
120
+ const wsBase = ws?.path
121
+ ? (isAbsolute(ws.path) ? ws.path : resolve(configDir, ws.path))
122
+ : (ws?.id ? resolve(configDir, "workspaces", ws.id) : null);
123
+ const wsRepos = ws?.repos || ws?.repositories || [];
124
+ if (!Array.isArray(wsRepos)) continue;
125
+ for (const repo of wsRepos) {
126
+ const repoPath = typeof repo === "string" ? repo : (repo?.path || repo?.repoRoot);
127
+ if (repoPath) {
128
+ pushPath(repoPath);
129
+ continue;
130
+ }
131
+ if (wsBase && repo?.name) pushPath(resolve(wsBase, repo.name));
132
+ }
133
+ }
134
+ }
135
+
136
+ return paths;
137
+ }
138
+
89
139
  function ensurePromptWorkspaceGitIgnore(repoRoot) {
90
140
  const gitignorePath = resolve(repoRoot, ".gitignore");
91
141
  const entry = "/.bosun/";
@@ -575,33 +625,26 @@ function detectRepoRoot() {
575
625
  }
576
626
 
577
627
  // 4. Check bosun config for workspace repos
578
- const configDir = process.env.BOSUN_DIR || resolve(process.env.HOME || process.env.USERPROFILE || "", "bosun");
628
+ const configDirs = getConfigSearchDirs();
629
+ let fallbackRepo = null;
579
630
  for (const cfgName of CONFIG_FILES) {
580
- const cfgPath = resolve(configDir, cfgName);
581
- if (existsSync(cfgPath)) {
631
+ for (const configDir of configDirs) {
632
+ const cfgPath = resolve(configDir, cfgName);
633
+ if (!existsSync(cfgPath)) continue;
582
634
  try {
583
635
  const cfg = JSON.parse(readFileSync(cfgPath, "utf8"));
584
- // Check workspace repos
585
- const repos = cfg.repositories || cfg.repos || [];
586
- if (Array.isArray(repos) && repos.length > 0) {
587
- const primary = repos.find((r) => r.primary) || repos[0];
588
- const repoPath = primary?.path || primary?.repoRoot;
589
- if (repoPath && existsSync(resolve(repoPath))) return resolve(repoPath);
636
+ const repoPaths = collectRepoPathsFromConfig(cfg, configDir);
637
+ for (const repoPath of repoPaths) {
638
+ if (!repoPath || !existsSync(repoPath)) continue;
639
+ if (existsSync(resolve(repoPath, ".git"))) return repoPath;
640
+ fallbackRepo ??= repoPath;
590
641
  }
591
- // Check workspaces
592
- const workspaces = cfg.workspaces;
593
- if (Array.isArray(workspaces) && workspaces.length > 0) {
594
- const ws = workspaces[0];
595
- const wsRepos = ws?.repos || ws?.repositories || [];
596
- if (Array.isArray(wsRepos) && wsRepos.length > 0) {
597
- const primary = wsRepos.find((r) => r.primary) || wsRepos[0];
598
- const repoPath = primary?.path || primary?.repoRoot;
599
- if (repoPath && existsSync(resolve(repoPath))) return resolve(repoPath);
600
- }
601
- }
602
- } catch { /* invalid config */ }
642
+ } catch {
643
+ /* invalid config */
644
+ }
603
645
  }
604
646
  }
647
+ if (fallbackRepo) return fallbackRepo;
605
648
 
606
649
  // 5. Final fallback — warn and return cwd. This is unlikely to be a valid
607
650
  // git repo (e.g. when the daemon spawns with cwd=homedir), but returning
@@ -2069,6 +2112,25 @@ export function loadConfig(argv = process.argv, options = {}) {
2069
2112
 
2070
2113
  // First run
2071
2114
  isFirstRun,
2115
+
2116
+ // Security controls
2117
+ security: Object.freeze({
2118
+ // List of trusted issue creators (primary GitHub account or configured list)
2119
+ trustedCreators:
2120
+ configData.trustedCreators ||
2121
+ process.env.BOSUN_TRUSTED_CREATORS?.split(",") ||
2122
+ [],
2123
+ // Enforce all new tasks go to backlog unless planner config allows auto-push
2124
+ enforceBacklog:
2125
+ typeof configData.enforceBacklog === "boolean"
2126
+ ? configData.enforceBacklog
2127
+ : true,
2128
+ // Control agent triggers: restrict agent activation to trusted sources
2129
+ agentTriggerControl:
2130
+ typeof configData.agentTriggerControl === "boolean"
2131
+ ? configData.agentTriggerControl
2132
+ : true,
2133
+ }),
2072
2134
  };
2073
2135
 
2074
2136
  return Object.freeze(config);
@@ -63,6 +63,10 @@ import {
63
63
  removeTask as removeInternalTask,
64
64
  updateTask as patchInternalTask,
65
65
  } from "./task-store.mjs";
66
+ import {
67
+ listTaskAttachments,
68
+ mergeTaskAttachments,
69
+ } from "./task-attachments.mjs";
66
70
  import { randomUUID } from "node:crypto";
67
71
 
68
72
  const TAG = "[kanban]";
@@ -269,6 +273,149 @@ function extractPrFromText(text) {
269
273
  return null;
270
274
  }
271
275
 
276
+ function isBosunStateComment(text) {
277
+ const raw = String(text || "").toLowerCase();
278
+ if (!raw) return false;
279
+ return raw.includes("bosun-state") || raw.includes("codex:ignore");
280
+ }
281
+
282
+ function normalizeAttachmentName(name, url) {
283
+ const raw = String(name || "").trim();
284
+ if (raw && !/^(?:https?:)?\/\//i.test(raw)) return raw;
285
+ if (!url) return raw || "attachment";
286
+ try {
287
+ const parsed = new URL(url);
288
+ const base = decodeURIComponent(parsed.pathname.split("/").pop() || "");
289
+ return base || raw || "attachment";
290
+ } catch {
291
+ const parts = String(url).split("/").filter(Boolean);
292
+ return parts[parts.length - 1] || raw || "attachment";
293
+ }
294
+ }
295
+
296
+ function guessAttachmentKind(url, name, isImage) {
297
+ const text = `${url || ""} ${name || ""}`.toLowerCase();
298
+ if (isImage) return "image";
299
+ if (/\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(text)) return "image";
300
+ return "file";
301
+ }
302
+
303
+ function guessContentType(name, kind) {
304
+ const text = String(name || "").toLowerCase();
305
+ if (kind === "image") {
306
+ if (text.endsWith(".png")) return "image/png";
307
+ if (text.endsWith(".jpg") || text.endsWith(".jpeg")) return "image/jpeg";
308
+ if (text.endsWith(".gif")) return "image/gif";
309
+ if (text.endsWith(".webp")) return "image/webp";
310
+ if (text.endsWith(".svg")) return "image/svg+xml";
311
+ }
312
+ if (text.endsWith(".pdf")) return "application/pdf";
313
+ if (text.endsWith(".json")) return "application/json";
314
+ if (text.endsWith(".csv")) return "text/csv";
315
+ if (text.endsWith(".txt") || text.endsWith(".log")) return "text/plain";
316
+ if (text.endsWith(".zip")) return "application/zip";
317
+ if (text.endsWith(".xlsx")) return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
318
+ if (text.endsWith(".docx")) return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
319
+ return kind === "image" ? "image/*" : "application/octet-stream";
320
+ }
321
+
322
+ function isLikelyAttachmentUrl(url, isImage = false) {
323
+ if (!url) return false;
324
+ const text = String(url).toLowerCase();
325
+ if (isImage) return true;
326
+ if (text.includes("user-images.githubusercontent.com")) return true;
327
+ if (text.includes("github.com/user-attachments")) return true;
328
+ if (text.includes("/attachments/")) return true;
329
+ if (text.includes("/files/")) return true;
330
+ return /\.(png|jpe?g|gif|webp|bmp|svg|pdf|json|csv|txt|log|zip|gz|tgz|xz|tar|rar|7z|docx?|xlsx?|pptx?)(\?|#|$)/i.test(text);
331
+ }
332
+
333
+ function extractMarkdownLinks(text) {
334
+ const raw = String(text || "");
335
+ if (!raw) return [];
336
+ const results = [];
337
+ const imageRe = /!\[([^\]]*)\]\(([^)]+)\)/g;
338
+ const linkRe = /\[([^\]]+)\]\(([^)]+)\)/g;
339
+ const htmlImgRe = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
340
+ const htmlLinkRe = /<a[^>]+href=["']([^"']+)["'][^>]*>/gi;
341
+ const urlRe = /\bhttps?:\/\/[^\s<>()]+/gi;
342
+
343
+ let match;
344
+ while ((match = imageRe.exec(raw))) {
345
+ const url = String(match[2] || "").trim().split(/\s+/)[0];
346
+ if (url) results.push({ url, label: match[1] || "", isImage: true });
347
+ }
348
+ while ((match = linkRe.exec(raw))) {
349
+ const url = String(match[2] || "").trim().split(/\s+/)[0];
350
+ if (url) results.push({ url, label: match[1] || "", isImage: false });
351
+ }
352
+ while ((match = htmlImgRe.exec(raw))) {
353
+ const url = String(match[1] || "").trim();
354
+ if (url) results.push({ url, label: "", isImage: true });
355
+ }
356
+ while ((match = htmlLinkRe.exec(raw))) {
357
+ const url = String(match[1] || "").trim();
358
+ if (url) results.push({ url, label: "", isImage: false });
359
+ }
360
+ while ((match = urlRe.exec(raw))) {
361
+ const url = String(match[0] || "").trim();
362
+ if (url) results.push({ url, label: "", isImage: false });
363
+ }
364
+
365
+ return results;
366
+ }
367
+
368
+ function extractAttachmentsFromText(text, meta = {}) {
369
+ const links = extractMarkdownLinks(text);
370
+ const attachments = [];
371
+ for (const link of links) {
372
+ if (!isLikelyAttachmentUrl(link.url, link.isImage)) continue;
373
+ const name = normalizeAttachmentName(link.label, link.url);
374
+ const kind = guessAttachmentKind(link.url, name, link.isImage);
375
+ attachments.push({
376
+ id: meta?.id || null,
377
+ name,
378
+ url: link.url,
379
+ kind,
380
+ contentType: meta?.contentType || guessContentType(name, kind),
381
+ size: meta?.size ?? null,
382
+ source: meta?.source || "unknown",
383
+ sourceType: meta?.sourceType || null,
384
+ commentId: meta?.commentId || null,
385
+ author: meta?.author || null,
386
+ createdAt: meta?.createdAt || null,
387
+ });
388
+ }
389
+ return attachments;
390
+ }
391
+
392
+ function normalizeCommentList(comments) {
393
+ if (!Array.isArray(comments)) return [];
394
+ return comments
395
+ .map((comment) => {
396
+ const body =
397
+ comment?.body ||
398
+ comment?.bodyText ||
399
+ comment?.body_html ||
400
+ comment?.text ||
401
+ "";
402
+ const trimmed = String(body || "").trim();
403
+ if (!trimmed || isBosunStateComment(trimmed)) return null;
404
+ return {
405
+ id: comment?.id || comment?.databaseId || null,
406
+ author:
407
+ comment?.author?.login ||
408
+ comment?.author?.displayName ||
409
+ comment?.author?.name ||
410
+ null,
411
+ createdAt: comment?.createdAt || comment?.created || null,
412
+ body: trimmed,
413
+ url: comment?.url || null,
414
+ };
415
+ })
416
+ .filter(Boolean);
417
+ }
418
+
272
419
  class InternalAdapter {
273
420
  constructor() {
274
421
  this.name = "internal";
@@ -291,6 +438,18 @@ class InternalAdapter {
291
438
  extractBaseBranchFromLabels(labelBag) ||
292
439
  extractBaseBranchFromText(task.description || task.body || ""),
293
440
  );
441
+ const rawComments = Array.isArray(task.meta?.comments)
442
+ ? task.meta.comments
443
+ : [];
444
+ const normalizedComments = normalizeCommentList(rawComments);
445
+ const existingAttachments = []
446
+ .concat(Array.isArray(task.attachments) ? task.attachments : [])
447
+ .concat(Array.isArray(task.meta?.attachments) ? task.meta.attachments : []);
448
+ const localAttachments = listTaskAttachments(task.id, "internal");
449
+ const mergedAttachments = mergeTaskAttachments(
450
+ existingAttachments,
451
+ localAttachments,
452
+ );
294
453
  return {
295
454
  id: String(task.id || ""),
296
455
  title: task.title || "",
@@ -317,7 +476,13 @@ class InternalAdapter {
317
476
  createdAt: task.createdAt || null,
318
477
  updatedAt: task.updatedAt || null,
319
478
  backend: "internal",
320
- meta: task.meta || {},
479
+ attachments: mergedAttachments,
480
+ comments: normalizedComments,
481
+ meta: {
482
+ ...(task.meta || {}),
483
+ comments: normalizedComments,
484
+ attachments: mergedAttachments,
485
+ },
321
486
  };
322
487
  }
323
488
 
@@ -3027,6 +3192,28 @@ To re-enable bosun for this task, remove the \`${this._codexLabels.ignore}\` lab
3027
3192
  extractBaseBranchFromLabels(labels) ||
3028
3193
  extractBaseBranchFromText(issue.body || ""),
3029
3194
  );
3195
+ const comments = normalizeCommentList(
3196
+ Array.isArray(issue.comments) ? issue.comments : [],
3197
+ );
3198
+ const descriptionAttachments = extractAttachmentsFromText(issue.body || "", {
3199
+ source: "github",
3200
+ sourceType: "issue",
3201
+ createdAt: issue.createdAt || issue.created_at || null,
3202
+ });
3203
+ const commentAttachments = comments.flatMap((comment) =>
3204
+ extractAttachmentsFromText(comment.body, {
3205
+ source: "github",
3206
+ sourceType: "comment",
3207
+ commentId: comment.id,
3208
+ author: comment.author,
3209
+ createdAt: comment.createdAt,
3210
+ }),
3211
+ );
3212
+ const localAttachments = listTaskAttachments(issue.number, "github");
3213
+ const mergedAttachments = mergeTaskAttachments(
3214
+ mergeTaskAttachments(descriptionAttachments, commentAttachments),
3215
+ localAttachments,
3216
+ );
3030
3217
 
3031
3218
  return {
3032
3219
  id: String(issue.number || ""),
@@ -3045,10 +3232,14 @@ To re-enable bosun for this task, remove the \`${this._codexLabels.ignore}\` lab
3045
3232
  baseBranch,
3046
3233
  branchName: branchMatch?.[1] || null,
3047
3234
  prNumber: prMatch?.[1] || null,
3235
+ attachments: mergedAttachments,
3236
+ comments,
3048
3237
  meta: {
3049
3238
  ...issue,
3050
3239
  task_url: issue.url || null,
3051
3240
  tags,
3241
+ comments,
3242
+ attachments: mergedAttachments,
3052
3243
  ...(baseBranch ? { base_branch: baseBranch, baseBranch } : {}),
3053
3244
  codex: codexMeta,
3054
3245
  },
@@ -3358,6 +3549,66 @@ class JiraAdapter {
3358
3549
  normalizedFieldValues[lcKey] = fieldValue;
3359
3550
  }
3360
3551
  }
3552
+ const rawComments = Array.isArray(fields.comment?.comments)
3553
+ ? fields.comment.comments
3554
+ : [];
3555
+ const comments = rawComments
3556
+ .map((comment) => {
3557
+ const body = this._commentToText(comment?.body).trim();
3558
+ if (!body || isBosunStateComment(body)) return null;
3559
+ return {
3560
+ id: comment?.id || null,
3561
+ author:
3562
+ comment?.author?.displayName ||
3563
+ comment?.author?.emailAddress ||
3564
+ comment?.author?.accountId ||
3565
+ null,
3566
+ createdAt: comment?.created || null,
3567
+ body,
3568
+ url: comment?.self || null,
3569
+ };
3570
+ })
3571
+ .filter(Boolean);
3572
+ const commentAttachments = comments.flatMap((comment) =>
3573
+ extractAttachmentsFromText(comment.body, {
3574
+ source: "jira",
3575
+ sourceType: "comment",
3576
+ commentId: comment.id,
3577
+ author: comment.author,
3578
+ createdAt: comment.createdAt,
3579
+ }),
3580
+ );
3581
+ const rawAttachments = Array.isArray(fields.attachment)
3582
+ ? fields.attachment
3583
+ : [];
3584
+ const jiraAttachments = rawAttachments
3585
+ .map((attachment) => {
3586
+ const name = attachment?.filename || attachment?.name || "attachment";
3587
+ const url = attachment?.content || attachment?.self || attachment?.thumbnail;
3588
+ if (!url) return null;
3589
+ const kind = guessAttachmentKind(
3590
+ url,
3591
+ name,
3592
+ String(attachment?.mimeType || "").startsWith("image/"),
3593
+ );
3594
+ return {
3595
+ id: attachment?.id || null,
3596
+ name,
3597
+ url,
3598
+ kind,
3599
+ contentType: attachment?.mimeType || guessContentType(name, kind),
3600
+ size: attachment?.size ?? null,
3601
+ source: "jira",
3602
+ sourceType: "attachment",
3603
+ createdAt: attachment?.created || null,
3604
+ };
3605
+ })
3606
+ .filter(Boolean);
3607
+ const localAttachments = listTaskAttachments(issueKey, "jira");
3608
+ const mergedAttachments = mergeTaskAttachments(
3609
+ mergeTaskAttachments(jiraAttachments, commentAttachments),
3610
+ localAttachments,
3611
+ );
3361
3612
  return {
3362
3613
  id: issueKey,
3363
3614
  title: fields.summary || "",
@@ -3378,11 +3629,15 @@ class JiraAdapter {
3378
3629
  taskUrl: issueKey ? `${this._baseUrl}/browse/${issueKey}` : null,
3379
3630
  createdAt: fields.created || null,
3380
3631
  updatedAt: fields.updated || null,
3632
+ attachments: mergedAttachments,
3633
+ comments,
3381
3634
  meta: {
3382
3635
  ...issue,
3383
3636
  labels,
3384
3637
  fields: normalizedFieldValues,
3385
3638
  tags,
3639
+ comments,
3640
+ attachments: mergedAttachments,
3386
3641
  ...(baseBranch ? { base_branch: baseBranch, baseBranch } : {}),
3387
3642
  codex: codexMeta,
3388
3643
  },
@@ -3546,7 +3801,7 @@ class JiraAdapter {
3546
3801
  const fieldList =
3547
3802
  fields.length > 0
3548
3803
  ? fields.join(",")
3549
- : "summary,description,status,assignee,priority,project,labels,comment,created,updated";
3804
+ : "summary,description,status,assignee,priority,project,labels,comment,attachment,created,updated";
3550
3805
  return this._jira(
3551
3806
  `/rest/api/3/issue/${encodeURIComponent(issueKey)}?fields=${encodeURIComponent(fieldList)}`,
3552
3807
  );
@@ -3735,7 +3990,7 @@ class JiraAdapter {
3735
3990
  const data = await this._searchIssues(
3736
3991
  jql,
3737
3992
  maxResults,
3738
- "summary,description,status,assignee,priority,project,labels,comment,created,updated",
3993
+ "summary,description,status,assignee,priority,project,labels,comment,attachment,created,updated",
3739
3994
  );
3740
3995
  let tasks = (Array.isArray(data?.issues) ? data.issues : []).map((issue) =>
3741
3996
  this._normaliseIssue(issue),