@spec-wave/cli 0.6.0 → 0.7.1

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.
@@ -1,11 +1,16 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { resolveToken } from '../api/auth.mjs';
4
- import { getIssue, createIssue, removeLabel, commentOnIssue } from '../api/github-rest.mjs';
5
- import { addSubIssue, addProjectItem, setItemSingleSelect, getSingleSelectField } from '../api/github-graphql.mjs';
4
+ import { getIssue, createIssue, removeLabel, addLabel, commentOnIssue, addBlockedBy } from '../api/github-rest.mjs';
5
+ import { addSubIssue, addProjectItem, setItemSingleSelect, getSingleSelectField, listSubIssues } from '../api/github-graphql.mjs';
6
6
  import { generateDocument } from '../lib/claude.mjs';
7
+ import { runCritique } from '../lib/critique.mjs';
8
+ import { recordUsage } from '../lib/usage-report.mjs';
9
+ import { formatDependencyLine } from '../lib/dependencies.mjs';
10
+ import { lintLanguage } from '../lib/output-lint.mjs';
7
11
  import { slugify } from '../lib/slugify.mjs';
8
- import { CONFIG_FILE } from '../config.mjs';
12
+ import { detectIssueType } from '../lib/issue-type.mjs';
13
+ import { CONFIG_FILE, DECOMPOSE_TARGETS, LABEL_DECOMPOSED, LABEL_CRITIQUE_FAILED, TARGET_LANGUAGE } from '../config.mjs';
9
14
 
10
15
  const READY_STAGE = 'Todo';
11
16
 
@@ -35,7 +40,66 @@ async function moveToStage(token, project, statusField, nodeId, stageName) {
35
40
  await setItemSingleSelect(token, project.id, itemId, statusField.id, optionId);
36
41
  }
37
42
 
38
- const SYSTEM_PROMPT = `Você é um Tech Lead experiente em decomposição de trabalho ágil.
43
+ // Extrai JSON da resposta do modelo (tolera texto em volta).
44
+ function parseJson(raw) {
45
+ try {
46
+ return JSON.parse(raw);
47
+ } catch {
48
+ const jsonMatch = raw.match(/\{[\s\S]*\}/);
49
+ if (!jsonMatch) throw new Error('Claude did not return valid JSON');
50
+ return JSON.parse(jsonMatch[0]);
51
+ }
52
+ }
53
+
54
+ // Prefixo de título das sub-issues geradas por cada tipo decompoível.
55
+ const CHILD_PREFIX = { Feature: '[STORY]', RFC: '[TASK]' };
56
+
57
+ /**
58
+ * Guard de idempotência do decompose (função PURA — testável).
59
+ *
60
+ * Skip se a issue já foi decomposta: label `spec-wave:decomposed` presente OU
61
+ * sub-issues já contêm um item do tipo-alvo (Feature → algum `[STORY]` no
62
+ * título; RFC → algum `[TASK]`). Sub-issues de outro tipo não contam.
63
+ *
64
+ * @param {object} params
65
+ * @param {Array<string|{name: string}>} [params.labels] labels da issue
66
+ * @param {Array<{ number?: number, title?: string }>} [params.subIssues] sub-issues existentes
67
+ * @param {string} params.type tipo da issue ('Feature' | 'RFC')
68
+ * @returns {{ skip: boolean, reason: string }}
69
+ */
70
+ export function shouldSkipDecompose({ labels = [], subIssues = [], type } = {}) {
71
+ const names = labels
72
+ .map(l => (typeof l === 'string' ? l : l?.name))
73
+ .filter(Boolean);
74
+ if (names.includes(LABEL_DECOMPOSED)) {
75
+ return { skip: true, reason: `a issue já tem a label \`${LABEL_DECOMPOSED}\`` };
76
+ }
77
+
78
+ const prefix = CHILD_PREFIX[type];
79
+ if (prefix) {
80
+ const existing = subIssues.find(s => (s.title || '').includes(prefix));
81
+ if (existing) {
82
+ const ref = existing.number ? ` (ex.: #${existing.number} — ${existing.title})` : '';
83
+ return { skip: true, reason: `a issue já tem sub-issues \`${prefix}\`${ref}` };
84
+ }
85
+ }
86
+
87
+ return { skip: false, reason: '' };
88
+ }
89
+
90
+ // Lint de idioma sobre títulos+corpos gerados; retorna aviso pronto para
91
+ // anexar ao comentário final ('' se limpo).
92
+ function formatItemsLintWarning(texts) {
93
+ const result = lintLanguage(texts.join('\n\n'), { lang: TARGET_LANGUAGE });
94
+ if (result.ok) return '';
95
+ const excerpts = result.findings
96
+ .slice(0, 5)
97
+ .map(f => `\`${f.excerpt.replace(/\s+/g, ' ').trim()}\``)
98
+ .join(', ');
99
+ return `\n\n⚠️ possíveis artefatos de idioma nos itens gerados: ${excerpts}`;
100
+ }
101
+
102
+ const FEATURE_SYSTEM_PROMPT = `Você é um Tech Lead experiente em decomposição de trabalho ágil.
39
103
  A partir da Feature fornecida (com spec.md e plan.md), gere uma lista de Stories e Tasks.
40
104
 
41
105
  Responda APENAS com JSON válido neste formato:
@@ -45,6 +109,7 @@ Responda APENAS com JSON válido neste formato:
45
109
  "title": "Título curto da story (apenas a parte 'quero', sem prefixo)",
46
110
  "userStory": "Como <perfil>, quero <objetivo>, para <benefício>",
47
111
  "body": "Descrição complementar da story com contexto e critérios de aceite relevantes",
112
+ "dependsOn": [0],
48
113
  "tasks": [
49
114
  {
50
115
  "title": "Título técnico curto da task (sem prefixo)",
@@ -61,48 +126,41 @@ Regras:
61
126
  - "body" é texto complementar (contexto, critérios de aceite); não repita o título
62
127
  - Cada Story deve ter 2–5 Tasks associadas
63
128
  - Tasks devem ser atividades técnicas concretas, com "title" curto e "body" detalhado
64
- - Gere entre 3 e 7 Stories por Feature`;
129
+ - Gere entre 3 e 7 Stories por Feature
130
+ - Ordene as stories na sequência de implementação — a ORDEM da lista importa
131
+ - "dependsOn" (opcional): índices 0-based das stories ANTERIORES na lista das quais esta story depende. Referencie apenas índices menores que o da própria story. Use [] quando a story puder ser feita em paralelo (sem dependências); se omitido, assume-se dependência da story anterior (sequencial)`;
65
132
 
66
- export async function decompose({ issueNumber }) {
67
- const token = await resolveToken();
68
- // PROJECT_TOKEN deve ter scope "project" para atualizar GitHub Projects v2.
69
- // Fallback para GITHUB_TOKEN (só funciona em repos pessoais sem org restrictions).
70
- const projectToken = process.env.PROJECT_TOKEN || token;
71
- const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/');
133
+ const RFC_SYSTEM_PROMPT = `Você é um Tech Lead experiente. A partir do RFC fornecido (proposta técnica/de processo), gere a lista de Tasks técnicas concretas necessárias para implementá-lo.
72
134
 
73
- if (!owner || !repo) {
74
- throw new Error(
75
- 'GITHUB_REPOSITORY env var não definida.\n' +
76
- 'Este comando roda no GitHub Actions. Para testar localmente:\n' +
77
- ' GITHUB_REPOSITORY=owner/repo spec-wave decompose --issue-number 1'
78
- );
79
- }
135
+ Responda APENAS com JSON válido neste formato:
136
+ {
137
+ "tasks": [
138
+ {
139
+ "title": "Título técnico curto da task (sem prefixo)",
140
+ "body": "Descrição técnica detalhada (o que fazer, áreas/arquivos afetados, critério de pronto)"
141
+ }
142
+ ]
143
+ }
80
144
 
81
- const issue = await getIssue(token, owner, repo, parseInt(issueNumber, 10));
82
- const slug = slugify(issue.title);
145
+ Regras:
146
+ - "title" CURTO (máx. ~60 caracteres), sem prefixo.
147
+ - "body" detalhado e acionável.
148
+ - Gere entre 3 e 10 Tasks concretas que, juntas, cubram o RFC.`;
83
149
 
84
- // Carrega projeto e resolve campo Status uma vez (reutilizado em todos os itens).
85
- const project = loadProject();
86
- let statusField = null;
87
- if (project?.id && READY_STAGE) {
88
- try {
89
- statusField = await resolveStatusField(projectToken, project);
90
- } catch (err) {
91
- console.warn(`Não foi possível resolver campo Status do board: ${err.message}`);
92
- }
93
- }
150
+ // Decompõe uma Feature em Stories (+ Tasks), cada uma vinculada como sub-issue.
151
+ async function decomposeFeature(ctx) {
152
+ const { token, projectToken, owner, repo, issue, issueNumber, project, statusField, usage } = ctx;
153
+ const slug = slugify(issue.title);
94
154
  const featureDir = `docs/features/${slug}`;
95
155
 
96
156
  const planContent = existsSync(`${featureDir}/plan.md`)
97
157
  ? readFileSync(`${featureDir}/plan.md`, 'utf-8')
98
158
  : '(plan.md não encontrado)';
99
-
100
159
  const specContent = existsSync(`${featureDir}/spec.md`)
101
160
  ? readFileSync(`${featureDir}/spec.md`, 'utf-8')
102
161
  : '(spec.md não encontrado)';
103
162
 
104
- console.log(`Decompondo feature: ${issue.title}`);
105
-
163
+ console.log(`Decompondo Feature: ${issue.title}`);
106
164
  const userContent = [
107
165
  `Feature: ${issue.title}`,
108
166
  `Issue #${issueNumber}`,
@@ -110,42 +168,80 @@ export async function decompose({ issueNumber }) {
110
168
  `\n## plan.md\n${planContent}`,
111
169
  ].join('\n');
112
170
 
113
- const raw = await generateDocument(SYSTEM_PROMPT, userContent);
171
+ const decomposition = parseJson(await generateDocument(FEATURE_SYSTEM_PROMPT, userContent, { action: 'decompose', usage }));
114
172
 
115
- let decomposition;
173
+ // Crítica adversarial ANTES de criar qualquer issue: stories que contradizem
174
+ // a spec/plan não devem virar trabalho. Crítica indisponível → só avisa.
175
+ let critique = null;
116
176
  try {
117
- decomposition = JSON.parse(raw);
118
- } catch {
119
- // Try to extract JSON from the response
120
- const jsonMatch = raw.match(/\{[\s\S]*\}/);
121
- if (!jsonMatch) throw new Error('Claude did not return valid JSON');
122
- decomposition = JSON.parse(jsonMatch[0]);
177
+ critique = await runCritique({
178
+ kind: 'stories',
179
+ spec: existsSync(`${featureDir}/spec.md`) ? specContent : null,
180
+ plan: existsSync(`${featureDir}/plan.md`) ? planContent : null,
181
+ stories: decomposition.stories,
182
+ usage,
183
+ });
184
+ } catch (err) {
185
+ console.warn(`Crítica adversarial indisponível: ${err.message}`);
186
+ await commentOnIssue(
187
+ token, owner, repo, parseInt(issueNumber, 10),
188
+ `⚠️ crítica adversarial indisponível (erro: ${err.message}) — prosseguindo com a decomposição.`
189
+ ).catch(() => {});
190
+ }
191
+ if (critique) {
192
+ await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10), critique.markdown)
193
+ .catch(err => console.warn(`Falha ao comentar a crítica: ${err.message}`));
194
+ if (critique.grave) {
195
+ console.log('Crítica adversarial apontou findings GRAVES — nenhuma story foi criada.');
196
+ await addLabel(token, owner, repo, parseInt(issueNumber, 10), LABEL_CRITIQUE_FAILED);
197
+ await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
198
+ return;
199
+ }
123
200
  }
124
201
 
125
- // node id da Feature — necessário para vincular as stories como sub-issues.
126
202
  const featureNodeId = issue.node_id;
127
-
128
203
  const created = [];
204
+ const createdStories = []; // issues criadas, na ordem dos índices das stories
205
+ const generatedTexts = []; // títulos+corpos para o lint de idioma final
129
206
 
130
- for (const story of decomposition.stories) {
207
+ for (let i = 0; i < decomposition.stories.length; i++) {
208
+ const story = decomposition.stories[i];
131
209
  console.log(`Criando story: ${story.title}`);
132
210
  const storyTitle = `[STORY] ${story.title}`;
133
- // Corpo: user story completa (Como/quero/para) + texto complementar.
134
- const storyBody = [story.userStory, story.body]
211
+ let storyBody = [story.userStory, story.body]
135
212
  .map(s => (s || '').trim())
136
213
  .filter(Boolean)
137
214
  .join('\n\n') || '_(sem descrição)_';
215
+
216
+ // Dependências: índices 0-based de stories anteriores. Default quando o
217
+ // campo está ausente: sequencial (story i depende da i-1). [] explícito =
218
+ // sem dependências. Índices inválidos/futuros são ignorados.
219
+ const depIndexes = Array.isArray(story.dependsOn)
220
+ ? [...new Set(story.dependsOn.filter(d => Number.isInteger(d) && d >= 0 && d < i))]
221
+ : (i > 0 ? [i - 1] : []);
222
+ const depIssues = depIndexes.map(idx => createdStories[idx]).filter(Boolean);
223
+ const depLine = formatDependencyLine(depIssues.map(d => d.number));
224
+ if (depLine) storyBody += `\n\n${depLine}`;
225
+
138
226
  const createdStory = await createIssue(token, owner, repo, storyTitle, storyBody, ['[STORY]']);
139
- created.push({ type: 'story', title: storyTitle, url: createdStory.url });
227
+ created.push({ title: storyTitle, url: createdStory.url });
228
+ createdStories.push(createdStory);
229
+ generatedTexts.push(storyTitle, storyBody);
230
+
231
+ // Relação nativa blocked_by (best-effort — a linha "Depende de" já basta).
232
+ for (const dep of depIssues) {
233
+ try {
234
+ await addBlockedBy(token, owner, repo, createdStory.number, dep.id);
235
+ } catch (err) {
236
+ console.warn(` Falha ao marcar story #${createdStory.number} como bloqueada por #${dep.number}: ${err.message}`);
237
+ }
238
+ }
140
239
 
141
- // Vincula a story como sub-issue da Feature (relação nativa do GitHub).
142
240
  try {
143
241
  await addSubIssue(token, featureNodeId, createdStory.nodeId);
144
242
  } catch (err) {
145
243
  console.warn(` Story #${createdStory.number} criada, mas falhou ao vincular à Feature: ${err.message}`);
146
244
  }
147
-
148
- // Move story para Ready no board.
149
245
  try {
150
246
  await moveToStage(projectToken, project, statusField, createdStory.nodeId, READY_STAGE);
151
247
  } catch (err) {
@@ -157,15 +253,12 @@ export async function decompose({ issueNumber }) {
157
253
  const taskTitle = `[TASK] ${task.title}`;
158
254
  const taskBody = `${task.body}\n\n_Story pai: ${createdStory.url}_`;
159
255
  const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]']);
160
-
161
- // Vincula a task como sub-issue da Story.
256
+ generatedTexts.push(taskTitle, taskBody);
162
257
  try {
163
258
  await addSubIssue(token, createdStory.nodeId, createdTask.nodeId);
164
259
  } catch (err) {
165
260
  console.warn(` Task #${createdTask.number} criada, mas falhou ao vincular à Story: ${err.message}`);
166
261
  }
167
-
168
- // Move task para Ready no board.
169
262
  try {
170
263
  await moveToStage(projectToken, project, statusField, createdTask.nodeId, READY_STAGE);
171
264
  } catch (err) {
@@ -174,7 +267,6 @@ export async function decompose({ issueNumber }) {
174
267
  }
175
268
  }
176
269
 
177
- // Move a própria Feature para Ready no board.
178
270
  try {
179
271
  await moveToStage(projectToken, project, statusField, featureNodeId, READY_STAGE);
180
272
  if (project?.id && statusField) console.log(`Feature movida para "${READY_STAGE}" no board.`);
@@ -182,16 +274,151 @@ export async function decompose({ issueNumber }) {
182
274
  console.warn(`Falha ao mover Feature para "${READY_STAGE}": ${err.message}`);
183
275
  }
184
276
 
185
- // Remove trigger label
277
+ // Marca a Feature como decomposta (guard de idempotência em runs futuros).
278
+ try {
279
+ await addLabel(token, owner, repo, parseInt(issueNumber, 10), LABEL_DECOMPOSED);
280
+ } catch (err) {
281
+ console.warn(`Falha ao aplicar a label ${LABEL_DECOMPOSED}: ${err.message}`);
282
+ }
186
283
  await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
187
-
188
- const storyList = created.map(s => `- ${s.url} — ${s.title}`).join('\n');
284
+ const list = created.map(s => `- ${s.url} — ${s.title}`).join('\n');
189
285
  await commentOnIssue(
190
286
  token, owner, repo, parseInt(issueNumber, 10),
191
287
  `🔀 **Decomposição concluída!**\n\n` +
192
- `Foram criados ${decomposition.stories.length} stories e suas tasks:\n\n${storyList}\n\n` +
193
- `Mova o card para **📋 Backlog Técnico** para iniciar o desenvolvimento.`
288
+ `Foram criados ${decomposition.stories.length} stories e suas tasks:\n\n${list}\n\n` +
289
+ `Mova o card para **📋 Backlog Técnico** para iniciar o desenvolvimento.` +
290
+ formatItemsLintWarning(generatedTexts)
194
291
  );
195
-
196
292
  console.log(`Decomposição concluída: ${decomposition.stories.length} stories criadas.`);
197
293
  }
294
+
295
+ // Decompõe um RFC diretamente em Tasks (sem Stories), cada uma vinculada como
296
+ // sub-issue do RFC.
297
+ async function decomposeRFC(ctx) {
298
+ const { token, projectToken, owner, repo, issue, issueNumber, project, statusField, usage } = ctx;
299
+ console.log(`Decompondo RFC: ${issue.title}`);
300
+
301
+ const userContent = [
302
+ `RFC: ${issue.title}`,
303
+ `Issue #${issueNumber}`,
304
+ `\n## Descrição\n${issue.body || '(sem descrição)'}`,
305
+ ].join('\n');
306
+
307
+ const decomposition = parseJson(await generateDocument(RFC_SYSTEM_PROMPT, userContent, { action: 'decompose', usage }));
308
+ const rfcNodeId = issue.node_id;
309
+ const tasks = decomposition.tasks || [];
310
+ const created = [];
311
+ const generatedTexts = []; // títulos+corpos para o lint de idioma final
312
+
313
+ for (const task of tasks) {
314
+ console.log(`Criando task: ${task.title}`);
315
+ const taskTitle = `[TASK] ${task.title}`;
316
+ const taskBody = `${task.body}\n\n_RFC pai: ${issue.html_url || `#${issueNumber}`}_`;
317
+ const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]']);
318
+ created.push({ title: taskTitle, url: createdTask.url });
319
+ generatedTexts.push(taskTitle, taskBody);
320
+
321
+ try {
322
+ await addSubIssue(token, rfcNodeId, createdTask.nodeId);
323
+ } catch (err) {
324
+ console.warn(` Task #${createdTask.number} criada, mas falhou ao vincular ao RFC: ${err.message}`);
325
+ }
326
+ try {
327
+ await moveToStage(projectToken, project, statusField, createdTask.nodeId, READY_STAGE);
328
+ } catch (err) {
329
+ console.warn(` Falha ao mover task #${createdTask.number} para "${READY_STAGE}": ${err.message}`);
330
+ }
331
+ }
332
+
333
+ // Marca o RFC como decomposto (guard de idempotência em runs futuros).
334
+ try {
335
+ await addLabel(token, owner, repo, parseInt(issueNumber, 10), LABEL_DECOMPOSED);
336
+ } catch (err) {
337
+ console.warn(`Falha ao aplicar a label ${LABEL_DECOMPOSED}: ${err.message}`);
338
+ }
339
+ await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
340
+ const list = created.map(t => `- ${t.url} — ${t.title}`).join('\n');
341
+ await commentOnIssue(
342
+ token, owner, repo, parseInt(issueNumber, 10),
343
+ `🔀 **Decomposição do RFC concluída!**\n\n` +
344
+ `Foram criadas ${created.length} tasks:\n\n${list}\n\n` +
345
+ `Mova o card para **📋 Backlog Técnico** para iniciar o desenvolvimento.` +
346
+ formatItemsLintWarning(generatedTexts)
347
+ );
348
+ console.log(`Decomposição concluída: ${created.length} tasks criadas.`);
349
+ }
350
+
351
+ export async function decompose({ issueNumber }) {
352
+ const token = await resolveToken();
353
+ // PROJECT_TOKEN deve ter scope "project" para atualizar GitHub Projects v2.
354
+ // Fallback para GITHUB_TOKEN (só funciona em repos pessoais sem org restrictions).
355
+ const projectToken = process.env.PROJECT_TOKEN || token;
356
+ const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/');
357
+
358
+ if (!owner || !repo) {
359
+ throw new Error(
360
+ 'GITHUB_REPOSITORY env var não definida.\n' +
361
+ 'Este comando roda no GitHub Actions. Para testar localmente:\n' +
362
+ ' GITHUB_REPOSITORY=owner/repo spec-wave decompose --issue-number 1'
363
+ );
364
+ }
365
+
366
+ const issue = await getIssue(token, owner, repo, parseInt(issueNumber, 10));
367
+ const type = detectIssueType(issue);
368
+
369
+ // Só Feature (→ Stories) e RFC (→ Tasks) podem ser decompostos.
370
+ if (!DECOMPOSE_TARGETS[type]) {
371
+ console.log(`Issue #${issueNumber} é ${type || 'desconhecido'} — decompose não se aplica.`);
372
+ await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
373
+ await commentOnIssue(
374
+ token, owner, repo, parseInt(issueNumber, 10),
375
+ `ℹ️ **decompose não se aplica a ${type || 'este tipo'}.** ` +
376
+ `Use em **Features** (gera Stories + Tasks) ou **RFCs** (gera Tasks).`
377
+ ).catch(() => {});
378
+ return;
379
+ }
380
+
381
+ // Guard de idempotência: label spec-wave:decomposed ou sub-issues do
382
+ // tipo-alvo já existentes → não re-decompõe (evita duplicar stories/tasks).
383
+ let subIssues = [];
384
+ try {
385
+ subIssues = await listSubIssues(token, issue.node_id);
386
+ } catch (err) {
387
+ console.warn(`Não foi possível listar sub-issues: ${err.message} — seguindo sem o guard de sub-issues.`);
388
+ subIssues = [];
389
+ }
390
+ const guard = shouldSkipDecompose({ labels: issue.labels || [], subIssues, type });
391
+ if (guard.skip) {
392
+ console.log(`Decompose ignorado: ${guard.reason}.`);
393
+ await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
394
+ await commentOnIssue(
395
+ token, owner, repo, parseInt(issueNumber, 10),
396
+ `⏭️ **decompose ignorado:** ${guard.reason}. Para forçar, remova a label ` +
397
+ `\`${LABEL_DECOMPOSED}\` (e apague as sub-issues antigas se quiser re-gerar).`
398
+ ).catch(() => {});
399
+ return;
400
+ }
401
+
402
+ // Projeto + campo Status (reutilizado em todos os itens).
403
+ const project = loadProject();
404
+ let statusField = null;
405
+ if (project?.id) {
406
+ try {
407
+ statusField = await resolveStatusField(projectToken, project);
408
+ } catch (err) {
409
+ console.warn(`Não foi possível resolver campo Status do board: ${err.message}`);
410
+ }
411
+ }
412
+
413
+ // Coletor de uso de IA — o finally registra o custo já incorrido mesmo nos
414
+ // fluxos que retornam cedo (ex.: abort da crítica grave) ou que falham.
415
+ const usageEntries = [];
416
+ const ctx = { token, projectToken, owner, repo, issue, issueNumber, project, statusField, usage: usageEntries };
417
+ try {
418
+ if (type === 'Feature') await decomposeFeature(ctx);
419
+ else if (type === 'RFC') await decomposeRFC(ctx);
420
+ } finally {
421
+ // Best-effort: nunca propaga erro (ver recordUsage).
422
+ await recordUsage({ token, owner, repo, issueNumber: parseInt(issueNumber, 10), entries: usageEntries });
423
+ }
424
+ }