raptor-aios 0.9.0 → 0.10.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/CHANGELOG.md CHANGED
@@ -3,6 +3,25 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
+ ## [0.10.0] - 2026-06-11
7
+
8
+ ### Added
9
+
10
+ - **Jira → spec: nomes de campo resolvidos + filtro de relevância (`packages/core/src/jira/`).** Um card de Jira corporativo carrega centenas de custom fields de configuração (o `CDPOS-6532` real: 1408 campos, 515 não-nulos) que antes inundavam a `## Problem Statement` com blocos `### customfield_NNNNN` ilegíveis. Agora:
11
+ - **Nomes resolvidos.** O dialeto Rovo (hospedado, OAuth) passa a enviar `expand: "names"`, então cada `customfield_NNNNN` ganha seu rótulo humano ("Critérios de Aceite"). O `mcp-atlassian` (≥ 0.11.2) tem o rótulo desembrulhado do wrapper `{value, name}` (ele nunca ecoa o mapa `names`); `renderedFields` deixou de ser pedido (o servidor o descarta e a combinação suprime custom fields em algumas versões).
12
+ - **Filtro de relevância (`filterCustomFields`).** Camadas determinísticas (PT/EN, singular+plural): valor-ruído (enums, datas, placeholders) e rótulos de config (Responsável, Relator, Categorias, Branch, Repositório, EI/EO/EQ, CMDB, Nova Data…) caem; allowlist de história (Requisitos, Cenários, Critérios de Aceite, Ressalva de testes, Figma, Traduções, Documentação, Anexo…) e links de ferramenta de design (figma/miro/zeplin/notion/confluence) ficam — avaliados ANTES do denylist. Desconhecidos só ficam com prosa substantiva (rede *lose-nothing*).
13
+ - **`jira.custom_fields` ganha `exclude` e force-keep** (chaves case-insensitive): `<campo>: exclude` derruba; `<campo>: <bucket>` força a manter.
14
+ - **Anexos de imagem** (`image/*`) entram como `### Attachments (images)` (links, não baixados); outros tipos ficam no card.
15
+ - **Critérios de Aceite agrupados por cenário** (`acceptanceLines`): blocos "Cenário N …" viram um `[AC-#]` cada (não um por linha de Gherkin).
16
+
17
+ ### Changed
18
+
19
+ - **`flattenAdf` rende `taskItem` e `table`.** Uma matriz de aplicabilidade do Jira (grade de checkboxes) agora vira texto legível: **só os itens marcados `[x]` sobrevivem** (os desmarcados são ruído) e tabelas viram uma linha por row com células separadas por `|`. No `CDPOS-6532`, "Cenários aplicáveis" encolheu de 2528 → 607 chars, mostrando o escopo real.
20
+ - **URL do card browsable.** `resolveUrl` prefere o `base_url` do `raptor.yml` ao `self` do gateway `api.atlassian.com` (que não abre no navegador); `jira.base_url` é encanado do `raptor.yml` ao cliente OAuth.
21
+ - **`raptor jira pull`** imprime quantos custom fields foram filtrados (descoberta para o escape hatch); `--json` segue despejando tudo sem filtro. O `jira-refresh.md` (clarify) usa o mesmo filtro — os três consumidores (`new`, `pull`, `jira-refresh`) mostram o mesmo card.
22
+
23
+ Doc: [`docs/jira-spec-enrichment.md`](docs/jira-spec-enrichment.md) (seções F1–F4 + F3½ + §4b card corporativo). Validado E2E contra o `CDPOS-6532` real via OAuth.
24
+
6
25
  ## [0.9.0] - 2026-06-11
7
26
 
8
27
  ### Added
@@ -12,7 +12,7 @@ export const ROVO_DIALECT = {
12
12
  transition: ["transitionJiraIssue"],
13
13
  },
14
14
  args: {
15
- getIssue: (cloudId, key) => ({ cloudId, issueIdOrKey: key }),
15
+ getIssue: (cloudId, key) => ({ cloudId, issueIdOrKey: key, expand: "names" }),
16
16
  search: (cloudId, jql, max) => ({ cloudId, jql, maxResults: max }),
17
17
  projects: (cloudId) => ({ cloudId }),
18
18
  createIssue: (input) => ({
@@ -53,7 +53,7 @@ export const MCP_ATLASSIAN_DIALECT = {
53
53
  getIssue: (_cloudId, key) => ({
54
54
  issue_key: key,
55
55
  fields: "*all",
56
- expand: "names,renderedFields",
56
+ expand: "names",
57
57
  comment_limit: 10,
58
58
  }),
59
59
  search: (_cloudId, jql, max) => ({ jql, limit: max }),
@@ -3,4 +3,4 @@ export * from "./credentials.js";
3
3
  export { AtlassianOAuthProvider, isAccessTokenExpired, openBrowser, startCallbackServer, } from "./oauth.js";
4
4
  export { connectJira, connectMcpServer, makeJiraClient, clientToToolCaller, decodeToolResult, unwrapEnvelope, expandEnv, missingEnvRefs, } from "./mcp-client.js";
5
5
  export { ROVO_DIALECT, MCP_ATLASSIAN_DIALECT, DIALECTS, resolveDialect, detectDialect, } from "./dialects.js";
6
- export { parseJiraIssue, parseCreatedIssue, extractComments, extractCustomFields, classifyField, flattenFieldValue, mapIssueToSpecContext, flattenAdf, extractAcceptanceCriteria, } from "./mapper.js";
6
+ export { parseJiraIssue, parseCreatedIssue, extractComments, extractCustomFields, extractAttachments, classifyField, filterCustomFields, isBoilerplateValue, isTemplateValue, acceptanceLines, flattenFieldValue, mapIssueToSpecContext, flattenAdf, extractAcceptanceCriteria, } from "./mapper.js";
@@ -33,18 +33,49 @@ export function parseJiraIssue(raw, baseUrl) {
33
33
  acceptanceCriteria: extractAcceptanceCriteria(fields, description),
34
34
  comments: extractComments(fields),
35
35
  customFields,
36
+ attachments: extractAttachments(fields),
36
37
  ...(url ? { url } : {}),
37
38
  };
38
39
  }
40
+ export function extractAttachments(fields) {
41
+ const list = Array.isArray(fields["attachment"])
42
+ ? fields["attachment"]
43
+ : Array.isArray(fields["attachments"])
44
+ ? fields["attachments"]
45
+ : [];
46
+ const out = [];
47
+ for (const a of list) {
48
+ if (!isRecord(a))
49
+ continue;
50
+ const filename = str(a["filename"]);
51
+ if (!filename)
52
+ continue;
53
+ out.push({
54
+ filename,
55
+ mimeType: str(a["mimeType"]) || str(a["content_type"]),
56
+ url: str(a["content"]) || str(a["url"]),
57
+ });
58
+ }
59
+ return out;
60
+ }
39
61
  export function extractCustomFields(fields, names) {
40
62
  const out = [];
41
63
  for (const [id, raw] of Object.entries(fields)) {
42
64
  if (!/^customfield_/i.test(id))
43
65
  continue;
44
- const value = flattenFieldValue(raw);
66
+ let label = str(names[id]);
67
+ let inner = raw;
68
+ if (isRecord(raw) &&
69
+ "value" in raw &&
70
+ Object.keys(raw).every((k) => k === "value" || k === "name")) {
71
+ if (!label)
72
+ label = str(raw["name"]);
73
+ inner = raw["value"];
74
+ }
75
+ const value = flattenFieldValue(inner);
45
76
  if (!value)
46
77
  continue;
47
- const name = str(names[id]) || id;
78
+ const name = label || id;
48
79
  out.push({ id, name, value, bucket: classifyField(name) });
49
80
  }
50
81
  return out;
@@ -55,21 +86,102 @@ export function classifyField(label) {
55
86
  return "acceptance";
56
87
  if (/cen[áa]rio|scenario/.test(l))
57
88
  return "scenarios";
58
- if (/n[ãa]o[-\s]?funcional|non[-\s]?functional|\brnf\b/.test(l))
89
+ if (/n[ãa]o[-\s]?funcional|non[-\s]?functional|\brnf\b|ressalva/.test(l))
59
90
  return "nonFunctional";
60
91
  if (/requisito|requirement|\brf\b/.test(l))
61
92
  return "functional";
62
93
  if (/fora do escopo|out of scope|n[ãa]o escopo/.test(l))
63
94
  return "outOfScope";
64
- if (/documenta|documentation|link|refer[êe]ncia|design|figma/.test(l))
95
+ if (/documenta|documentation|link|refer[êe]ncia|design|figma|tradu[çc]|translation/.test(l))
65
96
  return "docs";
66
97
  return "other";
67
98
  }
99
+ const ENUM_NOISE = /^(?:sim|n[ãa]o|yes|no|true|false|n\/a|na|none|nenhum|classificar|backlog|portal|no prazo|⏳|✅|❌|-+)$/i;
100
+ const PLACEHOLDER_LINE = /^(?:inserir|informar|descrever|preencher|selecionar|indicar|exportar|insira|informe|descreva|preencha|selecione|indique)\b/i;
101
+ const CARD_CONFIG_LABEL = /\b(?:respons[áa]ve(?:l|is)|relator(?:a|es|as)?|reporter|assignee|aprovador(?:a|es|as)?|squads?|categorias?|data\s+limite|nova\s+data|due\s*date|prazos?|branch(?:es)?|reposit[óo]rios?|repositor(?:y|ies)|entradas?\s+externas?|sa[íi]das?\s+externas?|consultas?\s+externas?|\bei\b|\beo\b|\beq\b|cmdb|aplica[çc][ãa]o\s+afetada|epic\s*links?|rank|development|sprints?|itera[çc](?:[ãa]o|[õo]es)|iterations?|quarters?|garantias?|story\s*points?|time\s*tracking)\b/i;
102
+ const ESSENTIAL_LABEL = /aceite|aceita[çc][ãa]o|acceptance|cen[áa]rio|scenario|requisito|requirement|\brnf\b|\brf\b|ressalva|figma|tradu[çc]|translation|documenta[çc]|documentation|refer[êe]ncia|\bdesign\b|anexo|attachment|prot[óo]tipo|prototype|hist[óo]ria|user\s*stor|fora\s+do\s+escopo|out\s+of\s+scope/i;
103
+ const DOC_TOOL_URL = /\b(?:figma\.com|miro\.com|zeplin\.(?:io|app)|notion\.so|atlassian\.net\/wiki)\b/i;
104
+ export function isBoilerplateValue(value) {
105
+ const v = value.trim();
106
+ if (!v)
107
+ return true;
108
+ if (ENUM_NOISE.test(v))
109
+ return true;
110
+ if (!v.includes("\n") && /^[\d|:.,\-T+Z\s/]+$/.test(v))
111
+ return true;
112
+ if (/^(?:espa[çc]o para inser[çc][ãa]o|campos a seguir)/i.test(v))
113
+ return true;
114
+ return false;
115
+ }
116
+ export function isTemplateValue(value) {
117
+ const v = value.trim();
118
+ if (v.length < 200)
119
+ return true;
120
+ if (v.includes("{panel") || v.includes("🚨"))
121
+ return true;
122
+ if (/^\{(?:pullrequest|dataType|json)\b/.test(v))
123
+ return true;
124
+ const segments = v
125
+ .split("\n")
126
+ .flatMap((l) => l.split(" | "))
127
+ .map((s) => s.replace(/^[-*\d.)\s]+/, "").trim())
128
+ .filter(Boolean);
129
+ return segments.filter((s) => PLACEHOLDER_LINE.test(s)).length >= 2;
130
+ }
131
+ export function filterCustomFields(fields, overrides = {}) {
132
+ const ovByKey = {};
133
+ for (const [k, v] of Object.entries(overrides))
134
+ ovByKey[k.toLowerCase()] = v;
135
+ const out = [];
136
+ for (const cf of fields) {
137
+ const ov = ovByKey[cf.id.toLowerCase()] ?? ovByKey[cf.name.toLowerCase()];
138
+ if (ov && ov.toLowerCase() === "exclude")
139
+ continue;
140
+ if (ov) {
141
+ out.push({ ...cf, bucket: ov });
142
+ continue;
143
+ }
144
+ if (isBoilerplateValue(cf.value))
145
+ continue;
146
+ if (ESSENTIAL_LABEL.test(cf.name)) {
147
+ out.push(cf);
148
+ continue;
149
+ }
150
+ if (DOC_TOOL_URL.test(cf.value)) {
151
+ out.push({ ...cf, bucket: "docs" });
152
+ continue;
153
+ }
154
+ if (CARD_CONFIG_LABEL.test(cf.name))
155
+ continue;
156
+ if (isTemplateValue(cf.value))
157
+ continue;
158
+ out.push(cf);
159
+ }
160
+ return out;
161
+ }
162
+ export function acceptanceLines(value) {
163
+ const lines = toLines(value);
164
+ const isStart = (l) => /^(?:cen[áa]rio|scenario)\b/i.test(l);
165
+ const starts = lines.filter(isStart).length;
166
+ const hasGherkin = lines.some((l) => /^(?:dado|quando|ent[ãa]o|given|when|then)\b/i.test(l));
167
+ if (starts === 0 || (starts === 1 && !hasGherkin))
168
+ return lines;
169
+ const blocks = [];
170
+ for (const l of lines) {
171
+ if (isStart(l) || blocks.length === 0)
172
+ blocks.push([l]);
173
+ else
174
+ blocks[blocks.length - 1].push(l);
175
+ }
176
+ return blocks.map((b) => b.join(" "));
177
+ }
68
178
  export function flattenFieldValue(raw) {
69
179
  if (raw == null)
70
180
  return "";
71
181
  if (typeof raw === "string")
72
182
  return raw.trim();
183
+ if (typeof raw === "number" || typeof raw === "boolean")
184
+ return String(raw);
73
185
  if (Array.isArray(raw)) {
74
186
  return raw.map(flattenFieldValue).filter(Boolean).join("\n").trim();
75
187
  }
@@ -113,11 +225,7 @@ export function extractComments(fields) {
113
225
  return out;
114
226
  }
115
227
  export function mapIssueToSpecContext(issue, opts = {}) {
116
- const overrides = opts.fieldBuckets ?? {};
117
- const custom = (issue.customFields ?? []).map((cf) => {
118
- const ov = overrides[cf.id] ?? overrides[cf.name.toLowerCase()];
119
- return ov ? { ...cf, bucket: ov } : cf;
120
- });
228
+ const custom = filterCustomFields(issue.customFields ?? [], opts.fieldBuckets);
121
229
  const ac = [];
122
230
  const seen = new Set();
123
231
  const pushAc = (line) => {
@@ -130,13 +238,19 @@ export function mapIssueToSpecContext(issue, opts = {}) {
130
238
  issue.acceptanceCriteria.forEach(pushAc);
131
239
  for (const cf of custom)
132
240
  if (cf.bucket === "acceptance")
133
- toLines(cf.value).forEach(pushAc);
241
+ acceptanceLines(cf.value).forEach(pushAc);
134
242
  const sections = custom
135
243
  .filter((cf) => cf.bucket !== "acceptance")
136
244
  .map((cf) => ({ title: cf.name, body: cf.value, bucket: cf.bucket }));
245
+ const images = (issue.attachments ?? []).filter((a) => /^image\//i.test(a.mimeType));
137
246
  const ticketBody = [
138
247
  issue.description,
139
248
  ...sections.map((s) => `### ${s.title}\n${s.body}`),
249
+ images.length
250
+ ? `### Attachments (images)\n${images
251
+ .map((a) => `- [${a.filename}](${a.url})`)
252
+ .join("\n")}`
253
+ : "",
140
254
  ]
141
255
  .filter((s) => s && s.trim())
142
256
  .join("\n\n");
@@ -175,6 +289,18 @@ export function flattenAdf(node) {
175
289
  if (url)
176
290
  return type === "inlineCard" ? url : `${url}\n`;
177
291
  }
292
+ if (type === "taskItem") {
293
+ const attrs = isRecord(node["attrs"]) ? node["attrs"] : {};
294
+ if (attrs["state"] !== "DONE")
295
+ return "";
296
+ return `- [x] ${flattenAdf(node["content"]).trim()}\n`;
297
+ }
298
+ if (type === "tableRow") {
299
+ const cells = Array.isArray(node["content"])
300
+ ? node["content"].map((c) => flattenAdf(c).trim().replace(/\s*\n\s*/g, " · "))
301
+ : [];
302
+ return `${cells.join(" | ")}\n`;
303
+ }
178
304
  const inner = flattenAdf(node["content"]);
179
305
  const blockTypes = new Set([
180
306
  "paragraph",
@@ -184,6 +310,8 @@ export function flattenAdf(node) {
184
310
  "listItem",
185
311
  "codeBlock",
186
312
  "blockquote",
313
+ "table",
314
+ "taskList",
187
315
  ]);
188
316
  if (type === "hardBreak")
189
317
  return "\n";
@@ -256,13 +384,17 @@ function resolveUrl(issue, fields, key, baseUrl) {
256
384
  if (direct)
257
385
  return direct;
258
386
  const self = str(issue["self"]);
259
- if (self) {
387
+ const selfOrigin = (() => {
260
388
  try {
261
- return `${new URL(self).origin}/browse/${key}`;
389
+ const u = new URL(self);
390
+ return u.hostname === "api.atlassian.com" ? undefined : u.origin;
262
391
  }
263
392
  catch {
393
+ return undefined;
264
394
  }
265
- }
395
+ })();
396
+ if (selfOrigin)
397
+ return `${selfOrigin}/browse/${key}`;
266
398
  if (baseUrl) {
267
399
  try {
268
400
  return `${new URL(baseUrl).origin}/browse/${key}`;
@@ -270,6 +402,13 @@ function resolveUrl(issue, fields, key, baseUrl) {
270
402
  catch {
271
403
  }
272
404
  }
405
+ if (self) {
406
+ try {
407
+ return `${new URL(self).origin}/browse/${key}`;
408
+ }
409
+ catch {
410
+ }
411
+ }
273
412
  return undefined;
274
413
  }
275
414
  function nestedName(v) {
@@ -90,6 +90,7 @@ export async function connectJira(opts = {}) {
90
90
  }
91
91
  return makeJiraClient(await clientToToolCaller(client), {
92
92
  dialect: ROVO_DIALECT,
93
+ ...(opts.baseUrl ? { baseUrl: opts.baseUrl } : {}),
93
94
  });
94
95
  }
95
96
  export async function connectMcpServer(cfg) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raptor/core",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js"
6
6
  }
@@ -111,8 +111,9 @@ and assets are incorporated, catalogued and present on disk — no loose ends.
111
111
  {{jiraTicketBody}}
112
112
 
113
113
  <!--
114
- Imported VERBATIM from Jira {{jira}} — the description PLUS every rich custom field
115
- (scenarios, requirements, test caveats, documentation/design links) the card holds.
114
+ Imported VERBATIM from Jira {{jira}} — the description PLUS every STORY-relevant custom
115
+ field (scenarios, requirements, test caveats, documentation/design links) the card holds;
116
+ card-config fields (workflow enums, form templates, tracking metadata) were filtered out.
116
117
  This is the authoritative source for this feature. The agent MUST redistribute ALL of
117
118
  it into the canonical sections below, LOSING NOTHING (each `### <field>` block too):
118
119
  - each user story / "História" → ## User Stories (as [US#] with priority, why, independent test)
@@ -123,6 +124,7 @@ it into the canonical sections below, LOSING NOTHING (each `### <field>` block t
123
124
  - test caveats / "Ressalvas de testes" → ## Edge Cases (or the relevant [NFR-#])
124
125
  - the ticket's out-of-scope / "Fora do Escopo" list → ## Out of Scope
125
126
  - design / documentation links (Figma etc.) → ## Dependencies & Assumptions
127
+ - "### Attachments (images)" → ## Design Reference / Dependencies & Assumptions (visual sources)
126
128
  Then REDUCE this section to a crisp problem statement (who is affected today and how). Do not leave the raw dump here.
127
129
  -->
128
130
  {{else}}{{#if description}}
@@ -1,6 +1,6 @@
1
1
  import { Args, Flags } from "@oclif/core";
2
2
  import { BaseCommand } from "../../base-command.js";
3
- import { mapIssueToSpecContext } from "../../_core/dist/index.js";
3
+ import { filterCustomFields, mapIssueToSpecContext } from "../../_core/dist/index.js";
4
4
  import { readConfig, requireProjectRoot } from "../../shared/project.js";
5
5
  import { jiraConn, openJiraInteractive } from "../../shared/jira.js";
6
6
  export default class JiraPull extends BaseCommand {
@@ -61,5 +61,11 @@ export default class JiraPull extends BaseCommand {
61
61
  for (const line of s.body.split("\n"))
62
62
  this.log(` ${line}`);
63
63
  }
64
+ const total = (issue.customFields ?? []).length;
65
+ const kept = filterCustomFields(issue.customFields ?? [], conn.customFields).length;
66
+ if (total > kept) {
67
+ this.log(`\n (${total - kept} of ${total} custom fields filtered as card config — ` +
68
+ `inspect with --json; force-keep via jira.custom_fields in raptor.yml)`);
69
+ }
64
70
  }
65
71
  }
@@ -1,6 +1,6 @@
1
1
  import { writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { connectJira, connectMcpServer, hasStoredTokens, parseSpec, } from "../_core/dist/index.js";
3
+ import { connectJira, connectMcpServer, filterCustomFields, hasStoredTokens, parseSpec, } from "../_core/dist/index.js";
4
4
  import { featureDir, readConfig } from "./project.js";
5
5
  export function jiraConn(config) {
6
6
  const j = config.jira;
@@ -10,6 +10,7 @@ export function jiraConn(config) {
10
10
  const common = {
11
11
  provider,
12
12
  ...(j.project_key ? { projectKey: j.project_key } : {}),
13
+ ...(j.base_url ? { baseUrl: j.base_url } : {}),
13
14
  statusSync: j.status_sync !== false,
14
15
  transitions: j.transitions ?? {},
15
16
  customFields: j.custom_fields ?? {},
@@ -36,7 +37,7 @@ export async function openJiraClient(conn) {
36
37
  throw new Error("Jira 'mcp' provider has no server config.");
37
38
  return connectMcpServer(conn.mcp);
38
39
  }
39
- return openJiraNonInteractive(conn.serverUrl);
40
+ return openJiraNonInteractive(conn.serverUrl, conn.baseUrl);
40
41
  }
41
42
  export async function openJiraInteractive(conn) {
42
43
  if (conn.provider === "mcp") {
@@ -44,11 +45,15 @@ export async function openJiraInteractive(conn) {
44
45
  throw new Error("Jira 'mcp' provider has no server config.");
45
46
  return connectMcpServer(conn.mcp);
46
47
  }
47
- return connectJira(conn.serverUrl ? { serverUrl: conn.serverUrl } : {});
48
+ return connectJira({
49
+ ...(conn.serverUrl ? { serverUrl: conn.serverUrl } : {}),
50
+ ...(conn.baseUrl ? { baseUrl: conn.baseUrl } : {}),
51
+ });
48
52
  }
49
- export async function openJiraNonInteractive(serverUrl) {
53
+ export async function openJiraNonInteractive(serverUrl, baseUrl) {
50
54
  return connectJira({
51
55
  ...(serverUrl ? { serverUrl } : {}),
56
+ ...(baseUrl ? { baseUrl } : {}),
52
57
  onAuthorizationUrl: () => {
53
58
  throw new Error("re-authorization required");
54
59
  },
@@ -81,7 +86,7 @@ export async function refreshJiraSidecar(cmd, root, feature, dir) {
81
86
  }
82
87
  try {
83
88
  const issue = await client.getJiraIssue(conn.cloudId, storyKey);
84
- writeFileSync(join(dir, "jira-refresh.md"), renderRefresh(issue));
89
+ writeFileSync(join(dir, "jira-refresh.md"), renderRefresh(issue, conn.customFields));
85
90
  cmd.log(` ↪ Jira ${storyKey}: refreshed → jira-refresh.md`);
86
91
  }
87
92
  catch (err) {
@@ -91,7 +96,7 @@ export async function refreshJiraSidecar(cmd, root, feature, dir) {
91
96
  await client.close();
92
97
  }
93
98
  }
94
- function renderRefresh(issue) {
99
+ function renderRefresh(issue, fieldOverrides = {}) {
95
100
  const lines = [
96
101
  `# Jira refresh — ${issue.key}`,
97
102
  "",
@@ -113,9 +118,15 @@ function renderRefresh(issue) {
113
118
  for (const ac of issue.acceptanceCriteria)
114
119
  lines.push(`- ${ac}`);
115
120
  }
116
- for (const cf of issue.customFields ?? []) {
121
+ for (const cf of filterCustomFields(issue.customFields ?? [], fieldOverrides)) {
117
122
  lines.push("", `## ${cf.name}`, "", cf.value);
118
123
  }
124
+ const images = (issue.attachments ?? []).filter((a) => /^image\//i.test(a.mimeType));
125
+ if (images.length) {
126
+ lines.push("", "## Attachments (images)");
127
+ for (const a of images)
128
+ lines.push(`- [${a.filename}](${a.url})`);
129
+ }
119
130
  if (issue.comments.length) {
120
131
  lines.push("", "## Recent comments");
121
132
  for (const c of issue.comments.slice(-5)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "raptor-aios",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Raptor — Spec-Driven Development (SDD) CLI for modern mobile apps. Constitutional gates, audit trail, real verification (a11y/perf/stores/OS matrix), and AI-agent slash commands.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,7 +29,7 @@ const CLI = join(ROOT, "packages", "cli");
29
29
  const CORE = join(ROOT, "packages", "core");
30
30
  const OUT = join(ROOT, "build", "npm");
31
31
 
32
- const VERSION = "0.9.0";
32
+ const VERSION = "0.10.0";
33
33
 
34
34
  function log(msg) {
35
35
  process.stdout.write(` ${msg}\n`);