@spec-wave/cli 0.12.0 → 0.14.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.
Files changed (40) hide show
  1. package/README.md +39 -27
  2. package/bin/spec-wave.mjs +14 -4
  3. package/package.json +1 -1
  4. package/src/api/github-graphql.mjs +0 -4
  5. package/src/api/github-rest.mjs +0 -13
  6. package/src/commands/code-review.mjs +5 -8
  7. package/src/commands/decompose.mjs +410 -251
  8. package/src/commands/dev-agent.mjs +3 -2
  9. package/src/commands/doctor.mjs +239 -9
  10. package/src/commands/generate-plan.mjs +111 -51
  11. package/src/commands/generate-spec.mjs +20 -22
  12. package/src/commands/implement.mjs +46 -24
  13. package/src/commands/info.mjs +4 -3
  14. package/src/commands/issue.mjs +4 -4
  15. package/src/commands/move.mjs +162 -0
  16. package/src/commands/order.mjs +1 -12
  17. package/src/commands/qa.mjs +5 -8
  18. package/src/commands/refresh.mjs +4 -3
  19. package/src/commands/story.mjs +1 -12
  20. package/src/commands/task.mjs +1 -11
  21. package/src/commands/update.mjs +43 -19
  22. package/src/commands/validate.mjs +47 -35
  23. package/src/config.mjs +40 -6
  24. package/src/lib/board.mjs +88 -26
  25. package/src/lib/claude.mjs +315 -70
  26. package/src/lib/critique.mjs +391 -91
  27. package/src/lib/decomposition-doc.mjs +451 -0
  28. package/src/lib/implement-board.mjs +14 -1
  29. package/src/lib/project-root.mjs +93 -0
  30. package/src/lib/templates.mjs +53 -0
  31. package/src/setup/files.mjs +3 -10
  32. package/src/templates/skill/SKILL.md +137 -61
  33. package/src/templates/workflows/code-review.yml +1 -1
  34. package/src/templates/workflows/decompose.yml +20 -6
  35. package/src/templates/workflows/generate-plan.yml +1 -1
  36. package/src/templates/workflows/generate-spec.yml +1 -1
  37. package/src/templates/workflows/qa.yml +1 -1
  38. package/src/templates/workflows/validate.yml +1 -1
  39. package/src/lib/feature-docs.mjs +0 -89
  40. package/src/lib/force.mjs +0 -34
@@ -1,20 +1,45 @@
1
- import { readFileSync } from 'node:fs';
1
+ // Decomposição de Feature Stories(+Tasks) e RFC → Tasks, em DUAS etapas.
2
+ //
3
+ // Antes era atômico: gerava o JSON, submetia à crítica, e se a crítica reprovasse
4
+ // descartava o rascunho inteiro. Os achados citavam "Story 5, task 1" de um
5
+ // artefato que não existia em lugar nenhum — nem na issue, nem no repo, nem nos
6
+ // logs. E como cada tentativa regenerava do zero, cada execução produzia uma
7
+ // lista diferente de achados: cinco tentativas, cinco listas.
8
+ //
9
+ // Agora:
10
+ // label spec-wave:decompose → grava/re-critica docs/**/decomposition.md
11
+ // label spec-wave:decompose-apply → cria as issues a partir do rascunho revisado
12
+ //
13
+ // A crítica passa a apontar para um arquivo ESTÁVEL, versionado, com âncoras
14
+ // ("Story 3", "Task 3.2"), e o humano corrige o artefato em vez de tentar
15
+ // influenciar indiretamente pelo plan.md. Um rascunho existente NUNCA é
16
+ // regenerado: re-aplicar :decompose critica o arquivo como está, preservando as
17
+ // edições humanas. Para regerar do zero, apague o arquivo.
18
+
19
+ import { execSync } from 'node:child_process';
20
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
21
+ import path from 'node:path';
2
22
  import { resolveToken } from '../api/auth.mjs';
3
- import { getIssue, createIssue, removeLabel, addLabel, commentOnIssue, addBlockedBy, closeIssue } from '../api/github-rest.mjs';
4
- import { addSubIssue, listSubIssues, addProjectItem, getItemSingleSelectValue } from '../api/github-graphql.mjs';
23
+ import {
24
+ getIssue, createIssue, removeLabel, addLabel, commentOnIssue, addBlockedBy, listIssueComments,
25
+ } from '../api/github-rest.mjs';
26
+ import { addSubIssue, listSubIssues } from '../api/github-graphql.mjs';
5
27
  import { loadProjectConfig, resolveField, advanceToStage } from '../lib/board.mjs';
6
- import { isForced, consumeForceLabel } from '../lib/force.mjs';
7
- import { resolveDoc } from '../lib/feature-docs.mjs';
8
28
  import { generateDocument } from '../lib/claude.mjs';
9
- import { runCritique } from '../lib/critique.mjs';
29
+ import { runCritique, resolveCritiqueAttempt, renderNeedsHumanComment } from '../lib/critique.mjs';
10
30
  import { recordUsage } from '../lib/usage-report.mjs';
11
31
  import { formatDependencyLine } from '../lib/dependencies.mjs';
12
32
  import { lintLanguage } from '../lib/output-lint.mjs';
13
33
  import { slugify } from '../lib/slugify.mjs';
14
34
  import { detectIssueType } from '../lib/issue-type.mjs';
35
+ import { loadConfig } from '../lib/project-root.mjs';
36
+ import {
37
+ renderDecompositionDoc, parseDecompositionDoc, DECOMPOSITION_FILE,
38
+ } from '../lib/decomposition-doc.mjs';
15
39
  import {
16
- DECOMPOSE_TARGETS, LABEL_DECOMPOSED, LABEL_CRITIQUE_FAILED, LABEL_FORCE,
17
- TARGET_LANGUAGE, STAGE_READY, STAGE_DEVELOPMENT, STAGE_ORDER, PROGRESS_TODO,
40
+ DECOMPOSE_TARGETS, LABEL_DECOMPOSE, LABEL_DECOMPOSE_APPLY, LABEL_DECOMPOSE_READY,
41
+ LABEL_DECOMPOSED, LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN, TARGET_LANGUAGE,
42
+ STAGE_READY, PROGRESS_TODO, DEFAULT_MAX_CRITIQUE_ATTEMPTS, labelNames,
18
43
  } from '../config.mjs';
19
44
 
20
45
  // Adiciona a issue ao board na Etapa ✅ Ready / Status Todo. Best-effort; a
@@ -24,6 +49,20 @@ async function moveToReady(token, project, etapaField, statusField, nodeId) {
24
49
  await advanceToStage(token, project, etapaField, statusField, nodeId, STAGE_READY, PROGRESS_TODO);
25
50
  }
26
51
 
52
+ /**
53
+ * Parada por decisão do fluxo (crítica grave, crítica indisponível, teto de
54
+ * tentativas): o comentário já foi postado, o catch externo não deve duplicá-lo,
55
+ * mas o erro PRECISA propagar. Antes esses caminhos faziam `return`, e a Action
56
+ * terminava verde tendo criado zero Stories — só se descobria olhando as labels.
57
+ */
58
+ class DecomposeBlockedError extends Error {
59
+ constructor(message) {
60
+ super(message);
61
+ this.name = 'DecomposeBlockedError';
62
+ this.blocked = true;
63
+ }
64
+ }
65
+
27
66
  // Dobra as barras invertidas que NÃO iniciam um escape válido de JSON. O corpo
28
67
  // das tasks costuma trazer trecho de shell/YAML/regex ("\d+", "gradlew \" no fim
29
68
  // da linha) e o modelo emite a barra crua: `JSON.parse` morre com "Bad escaped
@@ -84,6 +123,22 @@ export function parseModelJson(raw) {
84
123
  // Prefixo de título das sub-issues geradas por cada tipo decompoível.
85
124
  const CHILD_PREFIX = { Feature: '[STORY]', RFC: '[TASK]' };
86
125
 
126
+ /**
127
+ * Modo do run (função PURA — testável).
128
+ *
129
+ * A flag `--apply` vem do workflow, onde a label que disparou é a fonte de
130
+ * verdade. O fallback por label cobre a execução manual e o retry de runner.
131
+ *
132
+ * @param {object} params
133
+ * @param {Array<string|{name:string}>} [params.labels] labels da issue
134
+ * @param {boolean} [params.apply] flag --apply
135
+ * @returns {'draft'|'apply'}
136
+ */
137
+ export function resolveDecomposeMode({ labels = [], apply = false } = {}) {
138
+ if (apply) return 'apply';
139
+ return labelNames(labels).includes(LABEL_DECOMPOSE_APPLY) ? 'apply' : 'draft';
140
+ }
141
+
87
142
  /**
88
143
  * Guard de idempotência do decompose (função PURA — testável).
89
144
  *
@@ -91,22 +146,17 @@ const CHILD_PREFIX = { Feature: '[STORY]', RFC: '[TASK]' };
91
146
  * sub-issues já contêm um item do tipo-alvo (Feature → algum `[STORY]` no
92
147
  * título; RFC → algum `[TASK]`). Sub-issues de outro tipo não contam.
93
148
  *
94
- * Com `force` (flag `--force` ou label `spec-wave:force`) nada é pulado o
95
- * chamador é quem limpa a decomposição anterior antes de gerar a nova.
149
+ * `spec-wave:decompose-ready` de propósito NÃO entra aqui: é estado de rascunho
150
+ * pendente, não de decomposição feita re-aplicar `:decompose` deve re-criticar.
96
151
  *
97
152
  * @param {object} params
98
153
  * @param {Array<string|{name: string}>} [params.labels] labels da issue
99
154
  * @param {Array<{ number?: number, title?: string }>} [params.subIssues] sub-issues existentes
100
155
  * @param {string} params.type tipo da issue ('Feature' | 'RFC')
101
- * @param {boolean} [params.force] re-executa ignorando os guards
102
156
  * @returns {{ skip: boolean, reason: string }}
103
157
  */
104
- export function shouldSkipDecompose({ labels = [], subIssues = [], type, force = false } = {}) {
105
- if (force) return { skip: false, reason: '' };
106
-
107
- const names = labels
108
- .map(l => (typeof l === 'string' ? l : l?.name))
109
- .filter(Boolean);
158
+ export function shouldSkipDecompose({ labels = [], subIssues = [], type } = {}) {
159
+ const names = labelNames(labels);
110
160
  if (names.includes(LABEL_DECOMPOSED)) {
111
161
  return { skip: true, reason: `a issue já tem a label \`${LABEL_DECOMPOSED}\`` };
112
162
  }
@@ -123,101 +173,12 @@ export function shouldSkipDecompose({ labels = [], subIssues = [], type, force =
123
173
  return { skip: false, reason: '' };
124
174
  }
125
175
 
126
- /**
127
- * Planeja a limpeza de um re-decompose forçado (função PURA — testável).
128
- *
129
- * entram as sub-issues do tipo-alvo (Feature → `[STORY]`, RFC → `[TASK]`)
130
- * que ainda estão abertas as fechadas não precisam de nada. Separa as que
131
- * saíram de Ready (Etapa ≥ 🚧 Desenvolvimento): fechá-las descarta
132
- * trabalho em andamento, então elas são destacadas no aviso.
133
- *
134
- * @param {object} params
135
- * @param {Array<{number:number, title?:string, nodeId?:string, state?:string}>} [params.subIssues]
136
- * @param {string} params.type tipo da issue pai ('Feature' | 'RFC')
137
- * @param {Record<number, string|null>} [params.stages] Etapa atual por número de issue
138
- * @returns {{ close: object[], started: object[] }} close: a fechar (com `stage`);
139
- * started: subconjunto de close que já passou de Ready.
140
- */
141
- export function planForcedCleanup({ subIssues = [], type, stages = {} } = {}) {
142
- const prefix = CHILD_PREFIX[type];
143
- if (!prefix) return { close: [], started: [] };
144
-
145
- const devIdx = STAGE_ORDER.indexOf(STAGE_DEVELOPMENT);
146
- const close = subIssues
147
- .filter(s => (s.title || '').includes(prefix) && s.state !== 'closed')
148
- .map(s => ({ ...s, stage: stages[s.number] ?? null }));
149
- const started = close.filter(s => {
150
- const idx = s.stage ? STAGE_ORDER.indexOf(s.stage) : -1;
151
- return idx !== -1 && devIdx !== -1 && idx >= devIdx;
152
- });
153
- return { close, started };
154
- }
155
-
156
- // Fecha a decomposição anterior (sub-issues do tipo-alvo e os filhos delas) e
157
- // comenta o que foi fechado. Best-effort item a item: uma falha vira warn e o
158
- // re-decompose segue — melhor uma issue órfã do que abortar no meio.
159
- async function closeStaleSubIssues(ctx, subIssues) {
160
- const { token, projectToken, owner, repo, issueNumber, project, etapaField, type } = ctx;
161
-
162
- // Etapa de cada sub-issue (best-effort) — só para avisar o que já saiu de Ready.
163
- const stages = {};
164
- if (project?.id && etapaField?.id) {
165
- await Promise.all(subIssues.map(async (s) => {
166
- if (!s.nodeId) return;
167
- try {
168
- const itemId = await addProjectItem(projectToken, project.id, s.nodeId);
169
- stages[s.number] = await getItemSingleSelectValue(projectToken, itemId, etapaField.id);
170
- } catch {
171
- stages[s.number] = null;
172
- }
173
- }));
174
- }
175
-
176
- const { close, started } = planForcedCleanup({ subIssues, type, stages });
177
- if (close.length === 0) {
178
- console.log('Re-decompose forçado: nenhuma sub-issue anterior aberta para fechar.');
179
- return;
180
- }
181
-
182
- console.log(`Re-decompose forçado: fechando ${close.length} sub-issue(s) anterior(es)...`);
183
- const closed = [];
184
- for (const s of close) {
185
- // Filhos primeiro (Tasks de uma Story): fechar só a Story deixaria as Tasks
186
- // órfãs e abertas no board.
187
- const children = s.nodeId ? await listSubIssues(token, s.nodeId).catch(() => []) : [];
188
- for (const child of children.filter(c => c.state !== 'closed')) {
189
- try {
190
- await closeIssue(token, owner, repo, child.number);
191
- } catch (err) {
192
- console.warn(` Falha ao fechar #${child.number}: ${err.message}`);
193
- }
194
- }
195
- try {
196
- await closeIssue(token, owner, repo, s.number);
197
- closed.push({ ...s, children: children.length });
198
- console.log(` #${s.number} fechada${children.length ? ` (+${children.length} filha(s))` : ''}.`);
199
- } catch (err) {
200
- console.warn(` Falha ao fechar #${s.number}: ${err.message}`);
201
- }
202
- }
203
-
204
- if (closed.length === 0) return;
205
- const lines = closed.map(s =>
206
- `- #${s.number} ${s.title}${s.stage ? ` — Etapa ${s.stage}` : ''}` +
207
- (s.children ? ` _(+${s.children} sub-issue(s))_` : '')
208
- );
209
- const startedWarning = started.length > 0
210
- ? `\n\n⚠️ ${started.length} delas já tinha(m) saído de **${STAGE_READY}**: ` +
211
- `${started.map(s => `#${s.number} (${s.stage})`).join(', ')} — ` +
212
- 'confira se algum trabalho em andamento foi descartado.'
213
- : '';
214
- await commentOnIssue(
215
- token, owner, repo, parseInt(issueNumber, 10),
216
- `🔀 **Re-decompose forçado** (label \`${LABEL_FORCE}\`)\n\n` +
217
- `Fechadas ${closed.length} sub-issue(s) da decomposição anterior:\n\n${lines.join('\n')}` +
218
- startedWarning +
219
- '\n\nGerando a nova decomposição…'
220
- ).catch(() => {});
176
+ // Diretório do documento por tipo. Feature usa o mesmo docs/features/<slug> da
177
+ // spec/plan; RFC ganha o seu, que não passa por spec/plan.
178
+ function resolveDocDir(root, issue, type) {
179
+ const slug = slugify(issue.title);
180
+ const rel = type === 'RFC' ? `docs/rfcs/${slug}` : `docs/features/${slug}`;
181
+ return { slug, rel, dir: path.resolve(root || process.cwd(), rel) };
221
182
  }
222
183
 
223
184
  // Lint de idioma sobre títulos+corpos gerados; retorna aviso pronto para
@@ -232,6 +193,20 @@ function formatItemsLintWarning(texts) {
232
193
  return `\n\n⚠️ possíveis artefatos de idioma nos itens gerados: ${excerpts}`;
233
194
  }
234
195
 
196
+ // Grava e commita o rascunho — mesmo padrão do generate-plan (o workflow roda
197
+ // com contents: write e checkout com token).
198
+ function commitFile(filePath, content, message) {
199
+ mkdirSync(path.dirname(filePath), { recursive: true });
200
+ writeFileSync(filePath, content, 'utf-8');
201
+ const git = (cmd) => execSync(cmd, { stdio: 'inherit' });
202
+ git('git config user.email "spec-wave[bot]@github.com"');
203
+ git('git config user.name "spec-wave[bot]"');
204
+ git(`git add "${filePath}"`);
205
+ git(`git commit -m "${message}"`);
206
+ git('git pull --rebase');
207
+ git('git push');
208
+ }
209
+
235
210
  const FEATURE_SYSTEM_PROMPT = `Você é um Tech Lead experiente em decomposição de trabalho ágil.
236
211
  A partir da Feature fornecida (com spec.md e plan.md), gere uma lista de Stories e Tasks.
237
212
 
@@ -280,69 +255,239 @@ Regras:
280
255
  - "body" detalhado e acionável.
281
256
  - Gere entre 3 e 10 Tasks concretas que, juntas, cubram o RFC.`;
282
257
 
283
- // Decompõe uma Feature em Stories (+ Tasks), cada uma vinculada como sub-issue.
284
- async function decomposeFeature(ctx) {
285
- const { token, projectToken, owner, repo, issue, issueNumber, project, etapaField, statusField, usage } = ctx;
286
- const slug = slugify(issue.title);
287
- const featureDir = `docs/features/${slug}`;
288
-
289
- // Documentos ATUAIS (maior versão): com regerações forçadas o vigente é
290
- // spec-v2.md/plan-v3.md…, não o arquivo original.
291
- const spec = resolveDoc(featureDir, 'spec');
292
- const plan = resolveDoc(featureDir, 'plan');
293
- const specContent = spec ? readFileSync(spec.path, 'utf-8') : '(spec.md não encontrado)';
294
- const planContent = plan ? readFileSync(plan.path, 'utf-8') : '(plan.md não encontrado)';
295
-
296
- console.log(`Decompondo Feature: ${issue.title}`);
297
- if (spec || plan) {
298
- console.log(`Documentos: ${spec ? `${spec.path} (v${spec.version})` : 'spec ausente'} · ` +
299
- `${plan ? `${plan.path} (v${plan.version})` : 'plano ausente'}`);
258
+ // ---------------------------------------------------------------------------
259
+ // Etapa 1: rascunho (label spec-wave:decompose)
260
+ // ---------------------------------------------------------------------------
261
+
262
+ async function draftDecomposition(ctx) {
263
+ const { token, owner, repo, issue, issueNumber, type, labels, usage, docDir, docPath, docRel } = ctx;
264
+ const number = parseInt(issueNumber, 10);
265
+ const kind = DECOMPOSE_TARGETS[type]; // Feature 'stories'; RFC → 'tasks'
266
+ const blobUrl = `https://github.com/${owner}/${repo}/blob/main/${docRel}`;
267
+
268
+ const specPath = path.join(docDir, 'spec.md');
269
+ const planPath = path.join(docDir, 'plan.md');
270
+ const specContent = existsSync(specPath) ? readFileSync(specPath, 'utf-8') : null;
271
+ const planContent = existsSync(planPath) ? readFileSync(planPath, 'utf-8') : null;
272
+
273
+ // Rascunho existente é preservado COMO ESTÁ: o humano corrige o arquivo e
274
+ // re-aplica a label para uma nova crítica. Regenerar aqui apagaria a correção
275
+ // — é justamente o que fazia o ciclo não convergir.
276
+ let markdown;
277
+ if (existsSync(docPath)) {
278
+ console.log(`Rascunho encontrado em ${docRel} — criticando o arquivo como está (sem regerar).`);
279
+ markdown = readFileSync(docPath, 'utf-8');
280
+ } else {
281
+ console.log(`Gerando rascunho de decomposição para ${type}: ${issue.title}`);
282
+ const userContent = type === 'RFC'
283
+ ? [
284
+ `RFC: ${issue.title}`,
285
+ `Issue #${issueNumber}`,
286
+ `\n## Descrição\n${issue.body || '(sem descrição)'}`,
287
+ ].join('\n')
288
+ : [
289
+ `Feature: ${issue.title}`,
290
+ `Issue #${issueNumber}`,
291
+ `\n## spec.md\n${specContent || '(spec.md não encontrado)'}`,
292
+ `\n## plan.md\n${planContent || '(plan.md não encontrado)'}`,
293
+ ].join('\n');
294
+
295
+ const generated = parseModelJson(await generateDocument(
296
+ type === 'RFC' ? RFC_SYSTEM_PROMPT : FEATURE_SYSTEM_PROMPT,
297
+ userContent,
298
+ { action: 'decompose', labels, usage }
299
+ ));
300
+
301
+ markdown = renderDecompositionDoc({
302
+ title: issue.title,
303
+ issueNumber: number,
304
+ kind,
305
+ stories: generated.stories || [],
306
+ tasks: generated.tasks || [],
307
+ });
308
+ commitFile(docPath, markdown, `docs: rascunho de decomposição de ${docRel} [spec-wave]`);
309
+ console.log(`Rascunho commitado em ${docRel}.`);
310
+ }
311
+
312
+ // Valida a estrutura ANTES de gastar tokens com a crítica: um arquivo quebrado
313
+ // por edição humana precisa de mensagem clara, não de uma crítica sobre nada.
314
+ let doc;
315
+ try {
316
+ doc = parseDecompositionDoc(markdown);
317
+ } catch (err) {
318
+ await commentOnIssue(token, owner, repo, number,
319
+ `❌ **Não consegui ler o rascunho da decomposição.**\n\n` +
320
+ `\`\`\`\n${err.message}\n\`\`\`\n\n` +
321
+ `Corrija [\`${docRel}\`](${blobUrl}) e reaplique \`${LABEL_DECOMPOSE}\`. ` +
322
+ `Para começar de novo do zero, apague o arquivo.`
323
+ ).catch(() => {});
324
+ await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE).catch(() => {});
325
+ throw new DecomposeBlockedError(err.message);
326
+ }
327
+ const itemCount = kind === 'stories'
328
+ ? `${doc.stories.length} stories`
329
+ : `${doc.tasks.length} tasks`;
330
+ console.log(`Rascunho válido: ${itemCount}.`);
331
+
332
+ // O RFC não tem spec/plan para auditar contra — a crítica não teria referência.
333
+ if (kind !== 'stories') {
334
+ await finishDraft(ctx, { doc, blobUrl, itemCount, critiqued: false });
335
+ return;
336
+ }
337
+
338
+ const { attempt, blocked } = resolveCritiqueAttempt({
339
+ comments: ctx.comments, labels, kind: 'stories', maxAttempts: ctx.maxCritiqueAttempts,
340
+ });
341
+ if (blocked) {
342
+ console.log(`Teto de ${ctx.maxCritiqueAttempts} tentativas de crítica atingido — exigindo revisão humana.`);
343
+ await commentOnIssue(token, owner, repo, number, renderNeedsHumanComment({
344
+ kind: 'stories',
345
+ attempt,
346
+ maxAttempts: ctx.maxCritiqueAttempts,
347
+ escalationModel: ctx.escalationModel,
348
+ })).catch(() => {});
349
+ await addLabel(token, owner, repo, number, LABEL_NEEDS_HUMAN);
350
+ await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE);
351
+ throw new DecomposeBlockedError(
352
+ `A crítica reprovou ${ctx.maxCritiqueAttempts - 1}x seguidas. Label ${LABEL_NEEDS_HUMAN} ` +
353
+ 'aplicada: o fluxo exige revisão humana antes de continuar.'
354
+ );
300
355
  }
301
- const userContent = [
302
- `Feature: ${issue.title}`,
303
- `Issue #${issueNumber}`,
304
- `\n## spec.md\n${specContent}`,
305
- `\n## plan.md\n${planContent}`,
306
- ].join('\n');
307
-
308
- const decomposition = parseModelJson(await generateDocument(FEATURE_SYSTEM_PROMPT, userContent, { action: 'decompose', usage }));
309
-
310
- // Crítica adversarial ANTES de criar qualquer issue: stories que contradizem
311
- // a spec/plan não devem virar trabalho. Crítica indisponível → só avisa.
312
- let critique = null;
356
+
357
+ // Da segunda tentativa em diante, escala para o modelo mais forte configurado.
358
+ const model = attempt > 1 ? (ctx.escalationModel || undefined) : undefined;
359
+ if (model) console.log(`Tentativa ${attempt}: escalando a crítica para ${model}.`);
360
+
361
+ let critique;
313
362
  try {
314
363
  critique = await runCritique({
315
364
  kind: 'stories',
316
- spec: spec ? specContent : null,
317
- plan: plan ? planContent : null,
318
- stories: decomposition.stories,
365
+ spec: specContent,
366
+ plan: planContent,
367
+ decomposition: markdown,
368
+ attempt,
369
+ maxAttempts: ctx.maxCritiqueAttempts,
370
+ model,
371
+ labels,
319
372
  usage,
320
373
  });
321
374
  } catch (err) {
322
- console.warn(`Crítica adversarial indisponível: ${err.message}`);
323
- await commentOnIssue(
324
- token, owner, repo, parseInt(issueNumber, 10),
325
- `⚠️ crítica adversarial indisponível (erro: ${err.message}) prosseguindo com a decomposição.`
375
+ // A crítica é o portão que impede stories contraditórias de virarem trabalho.
376
+ // Liberar o rascunho com o portão comprovadamente não executado é exatamente
377
+ // o que se quer evitar — então aborta, e a Action fica vermelha.
378
+ await commentOnIssue(token, owner, repo, number,
379
+ `❌ **A crítica adversarial não concluiu.**\n\n` +
380
+ `\`\`\`\n${err.message}\n\`\`\`\n\n` +
381
+ `O rascunho está commitado em [\`${docRel}\`](${blobUrl}), mas **nada foi criado** — ` +
382
+ `sem crítica não há garantia de que as Stories respeitam a spec. ` +
383
+ `Reaplique \`${LABEL_DECOMPOSE}\` para tentar de novo.`
326
384
  ).catch(() => {});
385
+ await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE).catch(() => {});
386
+ throw new DecomposeBlockedError(`Crítica adversarial não concluiu: ${err.message}`);
327
387
  }
328
- if (critique) {
329
- await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10), critique.markdown)
330
- .catch(err => console.warn(`Falha ao comentar a crítica: ${err.message}`));
331
- if (critique.grave) {
332
- console.log('Crítica adversarial apontou findings GRAVES — nenhuma story foi criada.');
333
- await addLabel(token, owner, repo, parseInt(issueNumber, 10), LABEL_CRITIQUE_FAILED);
334
- await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
335
- return;
336
- }
388
+
389
+ await commentOnIssue(token, owner, repo, number, critique.markdown)
390
+ .catch(err => console.warn(`Falha ao comentar a crítica: ${err.message}`));
391
+
392
+ if (critique.grave) {
393
+ console.log('Crítica adversarial apontou findings GRAVES — nenhum item foi criado.');
394
+ await addLabel(token, owner, repo, number, LABEL_CRITIQUE_FAILED);
395
+ await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE_READY).catch(() => {});
396
+ await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE);
397
+ throw new DecomposeBlockedError(
398
+ `A crítica adversarial apontou findings graves no rascunho (tentativa ${attempt}). ` +
399
+ `Nenhuma issue foi criada. Corrija ${docRel} e reaplique ${LABEL_DECOMPOSE}.`
400
+ );
401
+ }
402
+
403
+ // Crítica limpa: remove o bloqueio anterior, que também zera o contador de
404
+ // tentativas na próxima rodada (ver resolveCritiqueAttempt).
405
+ await removeLabel(token, owner, repo, number, LABEL_CRITIQUE_FAILED).catch(() => {});
406
+ await finishDraft(ctx, { doc, blobUrl, itemCount, critiqued: true });
407
+ }
408
+
409
+ // Fecho comum do rascunho aprovado: libera para revisão humana e o apply.
410
+ async function finishDraft({ token, owner, repo, issueNumber, docRel }, { blobUrl, itemCount, critiqued }) {
411
+ const number = parseInt(issueNumber, 10);
412
+ await addLabel(token, owner, repo, number, LABEL_DECOMPOSE_READY);
413
+ await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE);
414
+ await commentOnIssue(token, owner, repo, number,
415
+ `📝 **Rascunho de decomposição pronto para revisão** (${itemCount}).\n\n` +
416
+ `📄 Arquivo: [\`${docRel}\`](${blobUrl})\n\n` +
417
+ (critiqued
418
+ ? 'A crítica adversarial não encontrou contradições graves. '
419
+ : '') +
420
+ `**Nada foi criado ainda.** Revise (e edite, se quiser) o arquivo e então crie as issues:\n` +
421
+ `\`\`\`\ngh issue edit ${issueNumber} --add-label "${LABEL_DECOMPOSE_APPLY}"\n\`\`\`\n` +
422
+ `Se preferir uma nova crítica depois de editar, reaplique \`${LABEL_DECOMPOSE}\` — ` +
423
+ 'o arquivo é criticado como está, sem ser regerado. Para gerar outro do zero, apague-o.'
424
+ ).catch(err => console.warn(`Falha ao comentar o rascunho: ${err.message}`));
425
+ console.log(`Rascunho liberado para revisão: ${itemCount}.`);
426
+ }
427
+
428
+ // ---------------------------------------------------------------------------
429
+ // Etapa 2: aplicação (label spec-wave:decompose-apply)
430
+ // ---------------------------------------------------------------------------
431
+
432
+ async function applyDecomposition(ctx) {
433
+ const { token, owner, repo, issueNumber, docPath, docRel } = ctx;
434
+ const number = parseInt(issueNumber, 10);
435
+
436
+ if (!existsSync(docPath)) {
437
+ await commentOnIssue(token, owner, repo, number,
438
+ `❌ **Não há rascunho de decomposição para aplicar.**\n\n` +
439
+ `Esperava encontrar \`${docRel}\`. Gere o rascunho primeiro:\n` +
440
+ `\`\`\`\ngh issue edit ${issueNumber} --add-label "${LABEL_DECOMPOSE}"\n\`\`\``
441
+ ).catch(() => {});
442
+ await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE_APPLY).catch(() => {});
443
+ throw new DecomposeBlockedError(`${docRel} não encontrado — aplique ${LABEL_DECOMPOSE} primeiro.`);
337
444
  }
338
445
 
446
+ let doc;
447
+ try {
448
+ doc = parseDecompositionDoc(readFileSync(docPath, 'utf-8'));
449
+ } catch (err) {
450
+ await commentOnIssue(token, owner, repo, number,
451
+ `❌ **Não consegui ler o rascunho da decomposição.**\n\n` +
452
+ `\`\`\`\n${err.message}\n\`\`\`\n\n` +
453
+ `Corrija \`${docRel}\` e reaplique \`${LABEL_DECOMPOSE_APPLY}\`.`
454
+ ).catch(() => {});
455
+ await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE_APPLY).catch(() => {});
456
+ throw new DecomposeBlockedError(err.message);
457
+ }
458
+
459
+ // O slug vem do TÍTULO da issue: se a Feature foi retitulada entre o rascunho e
460
+ // o apply, o arquivo encontrado pode ser de outra issue. Aviso, não erro — o
461
+ // humano é quem sabe se houve renomeação.
462
+ if (doc.issueNumber && doc.issueNumber !== number) {
463
+ console.warn(
464
+ `⚠️ ${docRel} foi gerado para a issue #${doc.issueNumber}, não a #${number} ` +
465
+ '(a issue foi retitulada?). Seguindo com o arquivo encontrado.'
466
+ );
467
+ }
468
+
469
+ // Campos do board só são resolvidos aqui: a etapa de rascunho não toca o board.
470
+ const { project, etapaField, statusField } = await resolveBoard(ctx);
471
+ const applyCtx = { ...ctx, project, etapaField, statusField };
472
+
473
+ if (doc.kind === 'tasks') await createTasksFromDoc(applyCtx, doc);
474
+ else await createStoriesFromDoc(applyCtx, doc);
475
+
476
+ await addLabel(token, owner, repo, number, LABEL_DECOMPOSED)
477
+ .catch(err => console.warn(`Falha ao aplicar a label ${LABEL_DECOMPOSED}: ${err.message}`));
478
+ await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE_APPLY);
479
+ await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE_READY).catch(() => {});
480
+ }
481
+
482
+ async function createStoriesFromDoc(ctx, doc) {
483
+ const { token, projectToken, owner, repo, issue, issueNumber, project, etapaField, statusField, docRel } = ctx;
339
484
  const featureNodeId = issue.node_id;
340
485
  const created = [];
341
486
  const createdStories = []; // issues criadas, na ordem dos índices das stories
342
487
  const generatedTexts = []; // títulos+corpos para o lint de idioma final
343
488
 
344
- for (let i = 0; i < decomposition.stories.length; i++) {
345
- const story = decomposition.stories[i];
489
+ for (let i = 0; i < doc.stories.length; i++) {
490
+ const story = doc.stories[i];
346
491
  console.log(`Criando story: ${story.title}`);
347
492
  const storyTitle = `[STORY] ${story.title}`;
348
493
  let storyBody = [story.userStory, story.body]
@@ -350,13 +495,8 @@ async function decomposeFeature(ctx) {
350
495
  .filter(Boolean)
351
496
  .join('\n\n') || '_(sem descrição)_';
352
497
 
353
- // Dependências: índices 0-based de stories anteriores. Default quando o
354
- // campo está ausente: sequencial (story i depende da i-1). [] explícito =
355
- // sem dependências. Índices inválidos/futuros são ignorados.
356
- const depIndexes = Array.isArray(story.dependsOn)
357
- ? [...new Set(story.dependsOn.filter(d => Number.isInteger(d) && d >= 0 && d < i))]
358
- : (i > 0 ? [i - 1] : []);
359
- const depIssues = depIndexes.map(idx => createdStories[idx]).filter(Boolean);
498
+ // dependsOn vem validado pelo parser (0-based, para trás).
499
+ const depIssues = story.dependsOn.map(idx => createdStories[idx]).filter(Boolean);
360
500
  const depLine = formatDependencyLine(depIssues.map(d => d.number));
361
501
  if (depLine) storyBody += `\n\n${depLine}`;
362
502
 
@@ -411,43 +551,23 @@ async function decomposeFeature(ctx) {
411
551
  console.warn(`Falha ao mover Feature para "${STAGE_READY}": ${err.message}`);
412
552
  }
413
553
 
414
- // Marca a Feature como decomposta (guard de idempotência em runs futuros).
415
- try {
416
- await addLabel(token, owner, repo, parseInt(issueNumber, 10), LABEL_DECOMPOSED);
417
- } catch (err) {
418
- console.warn(`Falha ao aplicar a label ${LABEL_DECOMPOSED}: ${err.message}`);
419
- }
420
- await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
421
554
  const list = created.map(s => `- ${s.url} — ${s.title}`).join('\n');
422
- await commentOnIssue(
423
- token, owner, repo, parseInt(issueNumber, 10),
424
- `🔀 **Decomposição concluída!**\n\n` +
425
- `Foram criados ${decomposition.stories.length} stories e suas tasks:\n\n${list}\n\n` +
555
+ await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10),
556
+ `🔀 **Decomposição aplicada!**\n\n` +
557
+ `A partir de \`${docRel}\` foram criados ${created.length} stories e suas tasks:\n\n${list}\n\n` +
426
558
  `Tudo posicionado em **✅ Ready**. Inicie o desenvolvimento com \`npx @spec-wave/cli@latest implement ${issueNumber}\` (Stories em ordem de dependência).` +
427
559
  formatItemsLintWarning(generatedTexts)
428
560
  );
429
- console.log(`Decomposição concluída: ${decomposition.stories.length} stories criadas.`);
561
+ console.log(`Decomposição aplicada: ${created.length} stories criadas.`);
430
562
  }
431
563
 
432
- // Decompõe um RFC diretamente em Tasks (sem Stories), cada uma vinculada como
433
- // sub-issue do RFC.
434
- async function decomposeRFC(ctx) {
435
- const { token, projectToken, owner, repo, issue, issueNumber, project, etapaField, statusField, usage } = ctx;
436
- console.log(`Decompondo RFC: ${issue.title}`);
437
-
438
- const userContent = [
439
- `RFC: ${issue.title}`,
440
- `Issue #${issueNumber}`,
441
- `\n## Descrição\n${issue.body || '(sem descrição)'}`,
442
- ].join('\n');
443
-
444
- const decomposition = parseModelJson(await generateDocument(RFC_SYSTEM_PROMPT, userContent, { action: 'decompose', usage }));
445
- const rfcNodeId = issue.node_id;
446
- const tasks = decomposition.tasks || [];
564
+ async function createTasksFromDoc(ctx, doc) {
565
+ const { token, projectToken, owner, repo, issue, issueNumber, project, etapaField, statusField, docRel } = ctx;
566
+ const parentNodeId = issue.node_id;
447
567
  const created = [];
448
- const generatedTexts = []; // títulos+corpos para o lint de idioma final
568
+ const generatedTexts = [];
449
569
 
450
- for (const task of tasks) {
570
+ for (const task of doc.tasks) {
451
571
  console.log(`Criando task: ${task.title}`);
452
572
  const taskTitle = `[TASK] ${task.title}`;
453
573
  const taskBody = `${task.body}\n\n_RFC pai: ${issue.html_url || `#${issueNumber}`}_`;
@@ -456,7 +576,7 @@ async function decomposeRFC(ctx) {
456
576
  generatedTexts.push(taskTitle, taskBody);
457
577
 
458
578
  try {
459
- await addSubIssue(token, rfcNodeId, createdTask.nodeId);
579
+ await addSubIssue(token, parentNodeId, createdTask.nodeId);
460
580
  } catch (err) {
461
581
  console.warn(` Task #${createdTask.number} criada, mas falhou ao vincular ao RFC: ${err.message}`);
462
582
  }
@@ -467,25 +587,42 @@ async function decomposeRFC(ctx) {
467
587
  }
468
588
  }
469
589
 
470
- // Marca o RFC como decomposto (guard de idempotência em runs futuros).
471
- try {
472
- await addLabel(token, owner, repo, parseInt(issueNumber, 10), LABEL_DECOMPOSED);
473
- } catch (err) {
474
- console.warn(`Falha ao aplicar a label ${LABEL_DECOMPOSED}: ${err.message}`);
475
- }
476
- await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
477
590
  const list = created.map(t => `- ${t.url} — ${t.title}`).join('\n');
478
- await commentOnIssue(
479
- token, owner, repo, parseInt(issueNumber, 10),
480
- `🔀 **Decomposição do RFC concluída!**\n\n` +
481
- `Foram criadas ${created.length} tasks:\n\n${list}\n\n` +
591
+ await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10),
592
+ `🔀 **Decomposição do RFC aplicada!**\n\n` +
593
+ `A partir de \`${docRel}\` foram criadas ${created.length} tasks:\n\n${list}\n\n` +
482
594
  `Tudo posicionado em **✅ Ready**. Inicie o desenvolvimento com \`npx @spec-wave/cli@latest implement <task>\`.` +
483
595
  formatItemsLintWarning(generatedTexts)
484
596
  );
485
- console.log(`Decomposição concluída: ${created.length} tasks criadas.`);
597
+ console.log(`Decomposição aplicada: ${created.length} tasks criadas.`);
486
598
  }
487
599
 
488
- export async function decompose({ issueNumber, force = false }) {
600
+ // Projeto + campos Etapa/Status do board (reutilizados em todos os itens).
601
+ async function resolveBoard({ projectToken, root }) {
602
+ const { project, error: projectError } = loadProjectConfig({ cwd: root || process.cwd() });
603
+ if (projectError) console.warn(`${projectError} — itens criados não serão posicionados no board.`);
604
+ let etapaField = null;
605
+ let statusField = null;
606
+ if (project?.id) {
607
+ try {
608
+ etapaField = await resolveField(projectToken, project, 'Etapa');
609
+ } catch (err) {
610
+ console.warn(`Não foi possível resolver campo Etapa do board: ${err.message}`);
611
+ }
612
+ try {
613
+ statusField = await resolveField(projectToken, project, 'Status');
614
+ } catch (err) {
615
+ console.warn(`Não foi possível resolver campo Status do board: ${err.message}`);
616
+ }
617
+ }
618
+ return { project, etapaField, statusField };
619
+ }
620
+
621
+ // ---------------------------------------------------------------------------
622
+ // Entrada
623
+ // ---------------------------------------------------------------------------
624
+
625
+ export async function decompose({ issueNumber, apply = false }) {
489
626
  const token = await resolveToken();
490
627
  // PROJECT_TOKEN deve ter scope "project" para atualizar GitHub Projects v2.
491
628
  // Fallback para GITHUB_TOKEN (só funciona em repos pessoais sem org restrictions).
@@ -500,21 +637,36 @@ export async function decompose({ issueNumber, force = false }) {
500
637
  );
501
638
  }
502
639
 
503
- const issue = await getIssue(token, owner, repo, parseInt(issueNumber, 10));
640
+ const number = parseInt(issueNumber, 10);
641
+ const issue = await getIssue(token, owner, repo, number);
504
642
  const type = detectIssueType(issue);
643
+ const labels = labelNames(issue);
644
+ const mode = resolveDecomposeMode({ labels, apply });
645
+ const trigger = mode === 'apply' ? LABEL_DECOMPOSE_APPLY : LABEL_DECOMPOSE;
505
646
 
506
647
  // Só Feature (→ Stories) e RFC (→ Tasks) podem ser decompostos.
507
648
  if (!DECOMPOSE_TARGETS[type]) {
508
649
  console.log(`Issue #${issueNumber} é ${type || 'desconhecido'} — decompose não se aplica.`);
509
- await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
510
- await commentOnIssue(
511
- token, owner, repo, parseInt(issueNumber, 10),
650
+ await removeLabel(token, owner, repo, number, trigger);
651
+ await commentOnIssue(token, owner, repo, number,
512
652
  `ℹ️ **decompose não se aplica a ${type || 'este tipo'}.** ` +
513
653
  `Use em **Features** (gera Stories + Tasks) ou **RFCs** (gera Tasks).`
514
654
  ).catch(() => {});
515
655
  return;
516
656
  }
517
657
 
658
+ // Revisão humana pendente: nada roda até a label sair.
659
+ if (labels.includes(LABEL_NEEDS_HUMAN)) {
660
+ console.log(`Decompose ignorado: a issue tem a label ${LABEL_NEEDS_HUMAN}.`);
661
+ await removeLabel(token, owner, repo, number, trigger);
662
+ await commentOnIssue(token, owner, repo, number,
663
+ `⏸️ **decompose parado:** a issue tem a label \`${LABEL_NEEDS_HUMAN}\`, aplicada depois de ` +
664
+ 'reprovas consecutivas da crítica. Corrija os documentos e remova a label ' +
665
+ `(e a \`${LABEL_CRITIQUE_FAILED}\`) para retomar o fluxo.`
666
+ ).catch(() => {});
667
+ return;
668
+ }
669
+
518
670
  // Guard de idempotência: label spec-wave:decomposed ou sub-issues do
519
671
  // tipo-alvo já existentes → não re-decompõe (evita duplicar stories/tasks).
520
672
  let subIssues = [];
@@ -524,56 +676,63 @@ export async function decompose({ issueNumber, force = false }) {
524
676
  console.warn(`Não foi possível listar sub-issues: ${err.message} — seguindo sem o guard de sub-issues.`);
525
677
  subIssues = [];
526
678
  }
527
- // Force (flag --force ou label spec-wave:force): ignora o guard e limpa a
528
- // decomposição anterior antes de gerar a nova.
529
- const forced = isForced({ labels: issue.labels || [], flag: force });
530
- const guard = shouldSkipDecompose({ labels: issue.labels || [], subIssues, type, force: forced });
679
+ const guard = shouldSkipDecompose({ labels, subIssues, type });
531
680
  if (guard.skip) {
532
681
  console.log(`Decompose ignorado: ${guard.reason}.`);
533
- await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
534
- await commentOnIssue(
535
- token, owner, repo, parseInt(issueNumber, 10),
536
- `⏭️ **decompose ignorado:** ${guard.reason}.\n\n` +
537
- `Para re-decompor, adicione a label \`${LABEL_FORCE}\` junto com \`spec-wave:decompose\` — ` +
538
- 'as sub-issues da decomposição anterior serão **fechadas** e novas serão geradas.'
682
+ await removeLabel(token, owner, repo, number, trigger);
683
+ await commentOnIssue(token, owner, repo, number,
684
+ `⏭️ **decompose ignorado:** ${guard.reason}. Para forçar, remova a label ` +
685
+ `\`${LABEL_DECOMPOSED}\` (e apague as sub-issues antigas se quiser re-gerar).`
539
686
  ).catch(() => {});
540
687
  return;
541
688
  }
542
- // Consome a label assim que ela é lida — se o run for cancelado/morto mais
543
- // adiante, ela não fica pendurada forçando silenciosamente os runs seguintes.
544
- await consumeForceLabel(token, owner, repo, parseInt(issueNumber, 10));
545
- if (forced) console.log(`Modo forçado ativo (${force ? 'flag --force' : `label ${LABEL_FORCE}`}).`);
546
689
 
547
- // Projeto + campos Etapa/Status do board (reutilizados em todos os itens).
548
- const { project, error: projectError } = loadProjectConfig();
549
- if (projectError) console.warn(`${projectError} itens criados não serão posicionados no board.`);
550
- let etapaField = null;
551
- let statusField = null;
552
- if (project?.id) {
553
- try {
554
- etapaField = await resolveField(projectToken, project, 'Etapa');
555
- } catch (err) {
556
- console.warn(`Não foi possível resolver campo Etapa do board: ${err.message}`);
557
- }
558
- try {
559
- statusField = await resolveField(projectToken, project, 'Status');
560
- } catch (err) {
561
- console.warn(`Não foi possível resolver campo Status do board: ${err.message}`);
562
- }
690
+ // Config do repo: raiz para os caminhos de documento e escalada da crítica.
691
+ const { config, root } = loadConfig();
692
+ const { rel: docRel, dir: docDir } = resolveDocDir(root, issue, type);
693
+ const docPath = path.join(docDir, DECOMPOSITION_FILE);
694
+
695
+ // Comentários só são necessários no rascunho (contador de tentativas).
696
+ let comments = [];
697
+ if (mode === 'draft') {
698
+ comments = await listIssueComments(token, owner, repo, number)
699
+ .catch(err => {
700
+ console.warn(`Não foi possível listar comentários: ${err.message} — contador de tentativas em 1.`);
701
+ return [];
702
+ });
563
703
  }
564
704
 
565
705
  // Coletor de uso de IA — o finally registra o custo já incorrido mesmo nos
566
- // fluxos que retornam cedo (ex.: abort da crítica grave) ou que falham.
706
+ // fluxos que abortam (ex.: crítica grave) ou que falham.
567
707
  const usageEntries = [];
568
- const ctx = { token, projectToken, owner, repo, issue, issueNumber, type, project, etapaField, statusField, usage: usageEntries };
708
+ const ctx = {
709
+ token, projectToken, owner, repo, issue, issueNumber, type, labels, comments,
710
+ root, docDir, docPath, docRel: `${docRel}/${DECOMPOSITION_FILE}`,
711
+ escalationModel: config?.ai?.escalationModel || null,
712
+ maxCritiqueAttempts:
713
+ Number.isInteger(config?.ai?.maxCritiqueAttempts) && config.ai.maxCritiqueAttempts > 0
714
+ ? config.ai.maxCritiqueAttempts
715
+ : DEFAULT_MAX_CRITIQUE_ATTEMPTS,
716
+ usage: usageEntries,
717
+ };
718
+
569
719
  try {
570
- // Limpeza do re-decompose forçado ANTES de gerar: fecha as sub-issues da
571
- // decomposição anterior para o board não ficar com dois conjuntos.
572
- if (forced) await closeStaleSubIssues(ctx, subIssues);
573
- if (type === 'Feature') await decomposeFeature(ctx);
574
- else if (type === 'RFC') await decomposeRFC(ctx);
720
+ if (mode === 'apply') await applyDecomposition(ctx);
721
+ else await draftDecomposition(ctx);
722
+ } catch (err) {
723
+ // Paradas por decisão do fluxo já comentaram na issue; erros inesperados não.
724
+ if (!err.blocked) {
725
+ await removeLabel(token, owner, repo, number, trigger).catch(() => {});
726
+ await commentOnIssue(token, owner, repo, number,
727
+ `❌ **Falha no decompose (${mode === 'apply' ? 'aplicação' : 'rascunho'}).**\n\n` +
728
+ `\`\`\`\n${err.message}\n\`\`\`\n\n` +
729
+ `A label \`${trigger}\` foi removida para destravar o gatilho — ` +
730
+ 'adicione-a de novo para tentar outra vez.'
731
+ ).catch(() => {});
732
+ }
733
+ throw err;
575
734
  } finally {
576
735
  // Best-effort: nunca propaga erro (ver recordUsage).
577
- await recordUsage({ token, owner, repo, issueNumber: parseInt(issueNumber, 10), entries: usageEntries });
736
+ await recordUsage({ token, owner, repo, issueNumber: number, entries: usageEntries });
578
737
  }
579
738
  }