@spec-wave/cli 0.13.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.
@@ -1,16 +1,46 @@
1
- import { existsSync, 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 } from '../api/github-rest.mjs';
23
+ import {
24
+ getIssue, createIssue, removeLabel, addLabel, commentOnIssue, addBlockedBy, listIssueComments,
25
+ } from '../api/github-rest.mjs';
4
26
  import { addSubIssue, listSubIssues } from '../api/github-graphql.mjs';
5
27
  import { loadProjectConfig, resolveField, advanceToStage } from '../lib/board.mjs';
6
28
  import { generateDocument } from '../lib/claude.mjs';
7
- import { runCritique } from '../lib/critique.mjs';
29
+ import { runCritique, resolveCritiqueAttempt, renderNeedsHumanComment } from '../lib/critique.mjs';
8
30
  import { recordUsage } from '../lib/usage-report.mjs';
9
31
  import { formatDependencyLine } from '../lib/dependencies.mjs';
10
32
  import { lintLanguage } from '../lib/output-lint.mjs';
11
33
  import { slugify } from '../lib/slugify.mjs';
12
34
  import { detectIssueType } from '../lib/issue-type.mjs';
13
- import { DECOMPOSE_TARGETS, LABEL_DECOMPOSED, LABEL_CRITIQUE_FAILED, TARGET_LANGUAGE, STAGE_READY, PROGRESS_TODO } from '../config.mjs';
35
+ import { loadConfig } from '../lib/project-root.mjs';
36
+ import {
37
+ renderDecompositionDoc, parseDecompositionDoc, DECOMPOSITION_FILE,
38
+ } from '../lib/decomposition-doc.mjs';
39
+ import {
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,
43
+ } from '../config.mjs';
14
44
 
15
45
  // Adiciona a issue ao board na Etapa ✅ Ready / Status Todo. Best-effort; a
16
46
  // Etapa nunca retrocede (advanceToStage não toca itens já adiante).
@@ -19,6 +49,20 @@ async function moveToReady(token, project, etapaField, statusField, nodeId) {
19
49
  await advanceToStage(token, project, etapaField, statusField, nodeId, STAGE_READY, PROGRESS_TODO);
20
50
  }
21
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
+
22
66
  // Dobra as barras invertidas que NÃO iniciam um escape válido de JSON. O corpo
23
67
  // das tasks costuma trazer trecho de shell/YAML/regex ("\d+", "gradlew \" no fim
24
68
  // da linha) e o modelo emite a barra crua: `JSON.parse` morre com "Bad escaped
@@ -79,6 +123,22 @@ export function parseModelJson(raw) {
79
123
  // Prefixo de título das sub-issues geradas por cada tipo decompoível.
80
124
  const CHILD_PREFIX = { Feature: '[STORY]', RFC: '[TASK]' };
81
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
+
82
142
  /**
83
143
  * Guard de idempotência do decompose (função PURA — testável).
84
144
  *
@@ -86,6 +146,9 @@ const CHILD_PREFIX = { Feature: '[STORY]', RFC: '[TASK]' };
86
146
  * sub-issues já contêm um item do tipo-alvo (Feature → algum `[STORY]` no
87
147
  * título; RFC → algum `[TASK]`). Sub-issues de outro tipo não contam.
88
148
  *
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.
151
+ *
89
152
  * @param {object} params
90
153
  * @param {Array<string|{name: string}>} [params.labels] labels da issue
91
154
  * @param {Array<{ number?: number, title?: string }>} [params.subIssues] sub-issues existentes
@@ -93,9 +156,7 @@ const CHILD_PREFIX = { Feature: '[STORY]', RFC: '[TASK]' };
93
156
  * @returns {{ skip: boolean, reason: string }}
94
157
  */
95
158
  export function shouldSkipDecompose({ labels = [], subIssues = [], type } = {}) {
96
- const names = labels
97
- .map(l => (typeof l === 'string' ? l : l?.name))
98
- .filter(Boolean);
159
+ const names = labelNames(labels);
99
160
  if (names.includes(LABEL_DECOMPOSED)) {
100
161
  return { skip: true, reason: `a issue já tem a label \`${LABEL_DECOMPOSED}\`` };
101
162
  }
@@ -112,6 +173,14 @@ export function shouldSkipDecompose({ labels = [], subIssues = [], type } = {})
112
173
  return { skip: false, reason: '' };
113
174
  }
114
175
 
176
+ // Diretório do documento por tipo. Feature usa o mesmo docs/features/<slug> da
177
+ // spec/plan; RFC ganha o seu, já 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) };
182
+ }
183
+
115
184
  // Lint de idioma sobre títulos+corpos gerados; retorna aviso pronto para
116
185
  // anexar ao comentário final ('' se limpo).
117
186
  function formatItemsLintWarning(texts) {
@@ -124,6 +193,20 @@ function formatItemsLintWarning(texts) {
124
193
  return `\n\n⚠️ possíveis artefatos de idioma nos itens gerados: ${excerpts}`;
125
194
  }
126
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
+
127
210
  const FEATURE_SYSTEM_PROMPT = `Você é um Tech Lead experiente em decomposição de trabalho ágil.
128
211
  A partir da Feature fornecida (com spec.md e plan.md), gere uma lista de Stories e Tasks.
129
212
 
@@ -172,65 +255,239 @@ Regras:
172
255
  - "body" detalhado e acionável.
173
256
  - Gere entre 3 e 10 Tasks concretas que, juntas, cubram o RFC.`;
174
257
 
175
- // Decompõe uma Feature em Stories (+ Tasks), cada uma vinculada como sub-issue.
176
- async function decomposeFeature(ctx) {
177
- const { token, projectToken, owner, repo, issue, issueNumber, project, etapaField, statusField, usage } = ctx;
178
- const slug = slugify(issue.title);
179
- const featureDir = `docs/features/${slug}`;
180
-
181
- const planContent = existsSync(`${featureDir}/plan.md`)
182
- ? readFileSync(`${featureDir}/plan.md`, 'utf-8')
183
- : '(plan.md não encontrado)';
184
- const specContent = existsSync(`${featureDir}/spec.md`)
185
- ? readFileSync(`${featureDir}/spec.md`, 'utf-8')
186
- : '(spec.md não encontrado)';
187
-
188
- console.log(`Decompondo Feature: ${issue.title}`);
189
- const userContent = [
190
- `Feature: ${issue.title}`,
191
- `Issue #${issueNumber}`,
192
- `\n## spec.md\n${specContent}`,
193
- `\n## plan.md\n${planContent}`,
194
- ].join('\n');
195
-
196
- const decomposition = parseModelJson(await generateDocument(FEATURE_SYSTEM_PROMPT, userContent, { action: 'decompose', usage }));
197
-
198
- // Crítica adversarial ANTES de criar qualquer issue: stories que contradizem
199
- // a spec/plan não devem virar trabalho. Crítica indisponível → só avisa.
200
- let critique = null;
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
+ );
355
+ }
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;
201
362
  try {
202
363
  critique = await runCritique({
203
364
  kind: 'stories',
204
- spec: existsSync(`${featureDir}/spec.md`) ? specContent : null,
205
- plan: existsSync(`${featureDir}/plan.md`) ? planContent : null,
206
- stories: decomposition.stories,
365
+ spec: specContent,
366
+ plan: planContent,
367
+ decomposition: markdown,
368
+ attempt,
369
+ maxAttempts: ctx.maxCritiqueAttempts,
370
+ model,
371
+ labels,
207
372
  usage,
208
373
  });
209
374
  } catch (err) {
210
- console.warn(`Crítica adversarial indisponível: ${err.message}`);
211
- await commentOnIssue(
212
- token, owner, repo, parseInt(issueNumber, 10),
213
- `⚠️ 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.`
214
384
  ).catch(() => {});
385
+ await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE).catch(() => {});
386
+ throw new DecomposeBlockedError(`Crítica adversarial não concluiu: ${err.message}`);
215
387
  }
216
- if (critique) {
217
- await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10), critique.markdown)
218
- .catch(err => console.warn(`Falha ao comentar a crítica: ${err.message}`));
219
- if (critique.grave) {
220
- console.log('Crítica adversarial apontou findings GRAVES — nenhuma story foi criada.');
221
- await addLabel(token, owner, repo, parseInt(issueNumber, 10), LABEL_CRITIQUE_FAILED);
222
- await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
223
- return;
224
- }
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.`);
444
+ }
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
+ );
225
467
  }
226
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;
227
484
  const featureNodeId = issue.node_id;
228
485
  const created = [];
229
486
  const createdStories = []; // issues criadas, na ordem dos índices das stories
230
487
  const generatedTexts = []; // títulos+corpos para o lint de idioma final
231
488
 
232
- for (let i = 0; i < decomposition.stories.length; i++) {
233
- const story = decomposition.stories[i];
489
+ for (let i = 0; i < doc.stories.length; i++) {
490
+ const story = doc.stories[i];
234
491
  console.log(`Criando story: ${story.title}`);
235
492
  const storyTitle = `[STORY] ${story.title}`;
236
493
  let storyBody = [story.userStory, story.body]
@@ -238,13 +495,8 @@ async function decomposeFeature(ctx) {
238
495
  .filter(Boolean)
239
496
  .join('\n\n') || '_(sem descrição)_';
240
497
 
241
- // Dependências: índices 0-based de stories anteriores. Default quando o
242
- // campo está ausente: sequencial (story i depende da i-1). [] explícito =
243
- // sem dependências. Índices inválidos/futuros são ignorados.
244
- const depIndexes = Array.isArray(story.dependsOn)
245
- ? [...new Set(story.dependsOn.filter(d => Number.isInteger(d) && d >= 0 && d < i))]
246
- : (i > 0 ? [i - 1] : []);
247
- 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);
248
500
  const depLine = formatDependencyLine(depIssues.map(d => d.number));
249
501
  if (depLine) storyBody += `\n\n${depLine}`;
250
502
 
@@ -299,43 +551,23 @@ async function decomposeFeature(ctx) {
299
551
  console.warn(`Falha ao mover Feature para "${STAGE_READY}": ${err.message}`);
300
552
  }
301
553
 
302
- // Marca a Feature como decomposta (guard de idempotência em runs futuros).
303
- try {
304
- await addLabel(token, owner, repo, parseInt(issueNumber, 10), LABEL_DECOMPOSED);
305
- } catch (err) {
306
- console.warn(`Falha ao aplicar a label ${LABEL_DECOMPOSED}: ${err.message}`);
307
- }
308
- await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
309
554
  const list = created.map(s => `- ${s.url} — ${s.title}`).join('\n');
310
- await commentOnIssue(
311
- token, owner, repo, parseInt(issueNumber, 10),
312
- `🔀 **Decomposição concluída!**\n\n` +
313
- `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` +
314
558
  `Tudo posicionado em **✅ Ready**. Inicie o desenvolvimento com \`npx @spec-wave/cli@latest implement ${issueNumber}\` (Stories em ordem de dependência).` +
315
559
  formatItemsLintWarning(generatedTexts)
316
560
  );
317
- console.log(`Decomposição concluída: ${decomposition.stories.length} stories criadas.`);
561
+ console.log(`Decomposição aplicada: ${created.length} stories criadas.`);
318
562
  }
319
563
 
320
- // Decompõe um RFC diretamente em Tasks (sem Stories), cada uma vinculada como
321
- // sub-issue do RFC.
322
- async function decomposeRFC(ctx) {
323
- const { token, projectToken, owner, repo, issue, issueNumber, project, etapaField, statusField, usage } = ctx;
324
- console.log(`Decompondo RFC: ${issue.title}`);
325
-
326
- const userContent = [
327
- `RFC: ${issue.title}`,
328
- `Issue #${issueNumber}`,
329
- `\n## Descrição\n${issue.body || '(sem descrição)'}`,
330
- ].join('\n');
331
-
332
- const decomposition = parseModelJson(await generateDocument(RFC_SYSTEM_PROMPT, userContent, { action: 'decompose', usage }));
333
- const rfcNodeId = issue.node_id;
334
- 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;
335
567
  const created = [];
336
- const generatedTexts = []; // títulos+corpos para o lint de idioma final
568
+ const generatedTexts = [];
337
569
 
338
- for (const task of tasks) {
570
+ for (const task of doc.tasks) {
339
571
  console.log(`Criando task: ${task.title}`);
340
572
  const taskTitle = `[TASK] ${task.title}`;
341
573
  const taskBody = `${task.body}\n\n_RFC pai: ${issue.html_url || `#${issueNumber}`}_`;
@@ -344,7 +576,7 @@ async function decomposeRFC(ctx) {
344
576
  generatedTexts.push(taskTitle, taskBody);
345
577
 
346
578
  try {
347
- await addSubIssue(token, rfcNodeId, createdTask.nodeId);
579
+ await addSubIssue(token, parentNodeId, createdTask.nodeId);
348
580
  } catch (err) {
349
581
  console.warn(` Task #${createdTask.number} criada, mas falhou ao vincular ao RFC: ${err.message}`);
350
582
  }
@@ -355,25 +587,42 @@ async function decomposeRFC(ctx) {
355
587
  }
356
588
  }
357
589
 
358
- // Marca o RFC como decomposto (guard de idempotência em runs futuros).
359
- try {
360
- await addLabel(token, owner, repo, parseInt(issueNumber, 10), LABEL_DECOMPOSED);
361
- } catch (err) {
362
- console.warn(`Falha ao aplicar a label ${LABEL_DECOMPOSED}: ${err.message}`);
363
- }
364
- await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
365
590
  const list = created.map(t => `- ${t.url} — ${t.title}`).join('\n');
366
- await commentOnIssue(
367
- token, owner, repo, parseInt(issueNumber, 10),
368
- `🔀 **Decomposição do RFC concluída!**\n\n` +
369
- `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` +
370
594
  `Tudo posicionado em **✅ Ready**. Inicie o desenvolvimento com \`npx @spec-wave/cli@latest implement <task>\`.` +
371
595
  formatItemsLintWarning(generatedTexts)
372
596
  );
373
- console.log(`Decomposição concluída: ${created.length} tasks criadas.`);
597
+ console.log(`Decomposição aplicada: ${created.length} tasks criadas.`);
598
+ }
599
+
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 };
374
619
  }
375
620
 
376
- export async function decompose({ issueNumber }) {
621
+ // ---------------------------------------------------------------------------
622
+ // Entrada
623
+ // ---------------------------------------------------------------------------
624
+
625
+ export async function decompose({ issueNumber, apply = false }) {
377
626
  const token = await resolveToken();
378
627
  // PROJECT_TOKEN deve ter scope "project" para atualizar GitHub Projects v2.
379
628
  // Fallback para GITHUB_TOKEN (só funciona em repos pessoais sem org restrictions).
@@ -388,21 +637,36 @@ export async function decompose({ issueNumber }) {
388
637
  );
389
638
  }
390
639
 
391
- 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);
392
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;
393
646
 
394
647
  // Só Feature (→ Stories) e RFC (→ Tasks) podem ser decompostos.
395
648
  if (!DECOMPOSE_TARGETS[type]) {
396
649
  console.log(`Issue #${issueNumber} é ${type || 'desconhecido'} — decompose não se aplica.`);
397
- await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
398
- await commentOnIssue(
399
- token, owner, repo, parseInt(issueNumber, 10),
650
+ await removeLabel(token, owner, repo, number, trigger);
651
+ await commentOnIssue(token, owner, repo, number,
400
652
  `ℹ️ **decompose não se aplica a ${type || 'este tipo'}.** ` +
401
653
  `Use em **Features** (gera Stories + Tasks) ou **RFCs** (gera Tasks).`
402
654
  ).catch(() => {});
403
655
  return;
404
656
  }
405
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
+
406
670
  // Guard de idempotência: label spec-wave:decomposed ou sub-issues do
407
671
  // tipo-alvo já existentes → não re-decompõe (evita duplicar stories/tasks).
408
672
  let subIssues = [];
@@ -412,45 +676,63 @@ export async function decompose({ issueNumber }) {
412
676
  console.warn(`Não foi possível listar sub-issues: ${err.message} — seguindo sem o guard de sub-issues.`);
413
677
  subIssues = [];
414
678
  }
415
- const guard = shouldSkipDecompose({ labels: issue.labels || [], subIssues, type });
679
+ const guard = shouldSkipDecompose({ labels, subIssues, type });
416
680
  if (guard.skip) {
417
681
  console.log(`Decompose ignorado: ${guard.reason}.`);
418
- await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
419
- await commentOnIssue(
420
- token, owner, repo, parseInt(issueNumber, 10),
682
+ await removeLabel(token, owner, repo, number, trigger);
683
+ await commentOnIssue(token, owner, repo, number,
421
684
  `⏭️ **decompose ignorado:** ${guard.reason}. Para forçar, remova a label ` +
422
685
  `\`${LABEL_DECOMPOSED}\` (e apague as sub-issues antigas se quiser re-gerar).`
423
686
  ).catch(() => {});
424
687
  return;
425
688
  }
426
689
 
427
- // Projeto + campos Etapa/Status do board (reutilizados em todos os itens).
428
- const { project, error: projectError } = loadProjectConfig();
429
- if (projectError) console.warn(`${projectError} itens criados não serão posicionados no board.`);
430
- let etapaField = null;
431
- let statusField = null;
432
- if (project?.id) {
433
- try {
434
- etapaField = await resolveField(projectToken, project, 'Etapa');
435
- } catch (err) {
436
- console.warn(`Não foi possível resolver campo Etapa do board: ${err.message}`);
437
- }
438
- try {
439
- statusField = await resolveField(projectToken, project, 'Status');
440
- } catch (err) {
441
- console.warn(`Não foi possível resolver campo Status do board: ${err.message}`);
442
- }
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
+ });
443
703
  }
444
704
 
445
705
  // Coletor de uso de IA — o finally registra o custo já incorrido mesmo nos
446
- // fluxos que retornam cedo (ex.: abort da crítica grave) ou que falham.
706
+ // fluxos que abortam (ex.: crítica grave) ou que falham.
447
707
  const usageEntries = [];
448
- const ctx = { token, projectToken, owner, repo, issue, issueNumber, 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
+
449
719
  try {
450
- if (type === 'Feature') await decomposeFeature(ctx);
451
- 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;
452
734
  } finally {
453
735
  // Best-effort: nunca propaga erro (ver recordUsage).
454
- await recordUsage({ token, owner, repo, issueNumber: parseInt(issueNumber, 10), entries: usageEntries });
736
+ await recordUsage({ token, owner, repo, issueNumber: number, entries: usageEntries });
455
737
  }
456
738
  }