@spec-wave/cli 0.26.0 → 0.28.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 (45) hide show
  1. package/package.json +1 -1
  2. package/src/api/github-rest.mjs +52 -0
  3. package/src/cli.mjs +12 -0
  4. package/src/commands/decompose.mjs +166 -39
  5. package/src/commands/doctor.mjs +214 -3
  6. package/src/commands/generate-bug.mjs +22 -16
  7. package/src/commands/generate-plan.mjs +72 -23
  8. package/src/commands/generate-spec.mjs +19 -15
  9. package/src/commands/implement.mjs +47 -22
  10. package/src/commands/install-skill.mjs +18 -8
  11. package/src/commands/preflight.mjs +322 -0
  12. package/src/commands/run.mjs +51 -30
  13. package/src/commands/update.mjs +143 -12
  14. package/src/commands/validate.mjs +84 -17
  15. package/src/config.mjs +18 -0
  16. package/src/lib/artifact-pr.mjs +272 -0
  17. package/src/lib/artifact-publish.mjs +169 -0
  18. package/src/lib/doc-availability.mjs +23 -1
  19. package/src/lib/doc-source.mjs +162 -0
  20. package/src/lib/flow-run.mjs +9 -218
  21. package/src/lib/next-step.mjs +27 -4
  22. package/src/lib/pr-branch.mjs +106 -7
  23. package/src/lib/repo-links.mjs +8 -2
  24. package/src/plugin/.claude-plugin/plugin.json +1 -1
  25. package/src/plugin/README.md +5 -0
  26. package/src/plugin/skills/bug/SKILL.md +2 -2
  27. package/src/plugin/skills/decompose/SKILL.md +4 -4
  28. package/src/plugin/skills/plan/SKILL.md +1 -1
  29. package/src/plugin/skills/preparar-feature/SKILL.md +245 -0
  30. package/src/plugin/skills/preparar-feature/reference/critica.md +88 -0
  31. package/src/plugin/skills/preparar-specs/SKILL.md +171 -0
  32. package/src/plugin/skills/preparar-specs/reference/armadilhas.md +209 -0
  33. package/src/plugin/skills/preparar-specs/reference/revisao.md +107 -0
  34. package/src/plugin/skills/run/SKILL.md +3 -1
  35. package/src/plugin/skills/spec/SKILL.md +4 -4
  36. package/src/plugin/skills/update/SKILL.md +10 -4
  37. package/src/plugin/skills/workflow/SKILL.md +8 -3
  38. package/src/templates/skill/SKILL.md +13 -10
  39. package/src/templates/workflows/code-review.yml +13 -2
  40. package/src/templates/workflows/critique.yml +1 -1
  41. package/src/templates/workflows/decompose.yml +13 -2
  42. package/src/templates/workflows/generate-bug.yml +17 -6
  43. package/src/templates/workflows/generate-plan.yml +20 -7
  44. package/src/templates/workflows/generate-spec.yml +20 -7
  45. package/src/templates/workflows/qa.yml +13 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spec-wave/cli",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
4
4
  "description": "Setup spec-driven GitHub workflow with Projects v2, labels, issue templates, and AI-powered Actions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -294,6 +294,33 @@ export async function createIssue(token, owner, repo, title, body, labels, { mil
294
294
  return { number: res.data.number, nodeId: res.data.node_id, url: res.data.html_url, id: res.data.id };
295
295
  }
296
296
 
297
+ // Milestones do repositório (abertas e fechadas).
298
+ //
299
+ // O usuário fala o TÍTULO da milestone ("v06"), a API de issues filtra pelo
300
+ // NÚMERO. A tradução mora aqui — e a lista completa também serve para a
301
+ // mensagem de erro: "milestone não encontrada" sem dizer quais existem manda o
302
+ // usuário adivinhar entre nome errado e API fora do ar.
303
+ export async function listMilestones(token, owner, repo) {
304
+ const octokit = makeOctokit(token);
305
+ return await octokit.paginate(octokit.rest.issues.listMilestones, {
306
+ owner, repo, state: 'all', per_page: 100,
307
+ });
308
+ }
309
+
310
+ // Issues de uma milestone (pelo NÚMERO dela), abertas e fechadas.
311
+ //
312
+ // `state: 'all'` de propósito: uma Feature fechada continua contando para o
313
+ // inventário — o que muda é que ela não entra na lista do que gerar.
314
+ export async function listIssuesByMilestone(token, owner, repo, milestoneNumber) {
315
+ const octokit = makeOctokit(token);
316
+ const issues = await octokit.paginate(octokit.rest.issues.listForRepo, {
317
+ owner, repo, milestone: String(milestoneNumber), state: 'all', per_page: 100,
318
+ });
319
+ // `listForRepo` devolve Pull Requests junto — eles são issues para a API, e
320
+ // não para o fluxo.
321
+ return issues.filter(i => !i.pull_request);
322
+ }
323
+
297
324
  export async function getIssue(token, owner, repo, issueNumber) {
298
325
  const octokit = makeOctokit(token);
299
326
  const res = await octokit.rest.issues.get({ owner, repo, issue_number: issueNumber });
@@ -311,6 +338,31 @@ export async function deleteLabel(token, owner, repo, name) {
311
338
  }
312
339
  }
313
340
 
341
+ /**
342
+ * Apaga a ref de uma branch.
343
+ *
344
+ * Existe para UM caso, e só ele: a branch de artefato que sobrou de um PR
345
+ * mergeado com squash. Nesse merge a ponta da branch não é ancestral da base, e
346
+ * empilhar o próximo commit nela produziria um PR que reintroduz estado antigo.
347
+ * Sem PR aberto e sem commits à frente da base, a branch não guarda nada — pode
348
+ * ser recriada a partir da base.
349
+ *
350
+ * NUNCA use isto para resolver conflito: apagar uma branch com PR aberto
351
+ * descartaria revisão humana.
352
+ *
353
+ * @returns {Promise<boolean>} false quando a branch já não existia
354
+ */
355
+ export async function deleteBranch(token, owner, repo, branch) {
356
+ const octokit = makeQuietOctokit(token); // 404 = já não existe, não é erro
357
+ try {
358
+ await octokit.rest.git.deleteRef({ owner, repo, ref: `heads/${branch}` });
359
+ return true;
360
+ } catch (err) {
361
+ if (err.status === 404 || err.status === 422) return false;
362
+ throw err;
363
+ }
364
+ }
365
+
314
366
  export async function deleteFile(token, owner, repo, filePath, message) {
315
367
  const octokit = makeOctokit(token);
316
368
  let sha;
package/src/cli.mjs CHANGED
@@ -162,6 +162,16 @@ export function buildProgram() {
162
162
  await run(issue, options).catch(err => { console.error(err.message); process.exit(1); });
163
163
  });
164
164
 
165
+ program
166
+ .command('preflight')
167
+ .description('Confere, antes de gerar, tudo que decide uma rodada de specs de uma milestone')
168
+ .requiredOption('--milestone <nome>', 'Título da milestone a inventariar')
169
+ .option('--json', 'Imprime o relatório em JSON')
170
+ .action(async (options) => {
171
+ const { preflight } = await import('./commands/preflight.mjs');
172
+ await preflight(options).catch(err => { console.error(err.message); process.exit(1); });
173
+ });
174
+
165
175
  program
166
176
  .command('mode')
167
177
  .description('Mostra ou alterna o modo de execução: `actions` (workflows) ou `local` (esta máquina)')
@@ -188,6 +198,8 @@ export function buildProgram() {
188
198
  .option('--branch [nome]', 'Envia os arquivos do repo como Pull Request numa branch, em um único commit (sem valor: spec-wave/update-v<versão>)')
189
199
  .option('--config-in-pr', 'Força incluir o .spec-wave.json no Pull Request')
190
200
  .option('--no-config-in-pr', 'Força manter o .spec-wave.json fora do Pull Request')
201
+ .option('--skill-in-pr', 'Força incluir a skill dos agentes no Pull Request')
202
+ .option('--no-skill-in-pr', 'Força manter a skill dos agentes fora do Pull Request')
191
203
  .option('--dry-run', 'Mostra o que seria atualizado sem alterar nada')
192
204
  .option('--yes', 'Aplica sem pedir confirmação')
193
205
  .action(async (options) => {
@@ -17,11 +17,10 @@
17
17
  // edições humanas. Para regerar do zero, apague o arquivo.
18
18
 
19
19
  import { execSync } from 'node:child_process';
20
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
21
- import path from 'node:path';
22
20
  import { resolveToken } from '../api/auth.mjs';
23
21
  import {
24
22
  getIssue, createIssue, removeLabel, addLabel, commentOnIssue, addBlockedBy, listIssueComments,
23
+ getRepoDefaultBranch,
25
24
  } from '../api/github-rest.mjs';
26
25
  import { addSubIssue, listSubIssues, getProjectSnapshot } from '../api/github-graphql.mjs';
27
26
  import { loadProjectConfig, resolveField, advanceToStage } from '../lib/board.mjs';
@@ -37,7 +36,11 @@ import { resolveDocDir } from '../lib/doc-paths.mjs';
37
36
  import { docBlobUrl } from '../lib/repo-links.mjs';
38
37
  import { detectIssueType } from '../lib/issue-type.mjs';
39
38
  import { loadConfig } from '../lib/project-root.mjs';
40
- import { resolveFlowContext, commitGenerated } from '../lib/flow-run.mjs';
39
+ import { resolveFlowContext } from '../lib/flow-run.mjs';
40
+ import { publishArtifact } from '../lib/artifact-publish.mjs';
41
+ import { loadArtifact } from '../lib/doc-source.mjs';
42
+ import { awaitingMergeBlock } from '../lib/artifact-pr.mjs';
43
+ import { isAwaitingMerge } from '../lib/doc-source.mjs';
41
44
  import { loadPrompt, systemPromptWithTools } from '../lib/prompt-loader.mjs';
42
45
  import {
43
46
  renderDecompositionDoc, parseDecompositionDoc, DECOMPOSITION_FILE,
@@ -246,11 +249,26 @@ function formatItemsLintWarning(texts) {
246
249
  return `\n\n⚠️ possíveis artefatos de idioma nos itens gerados: ${excerpts}`;
247
250
  }
248
251
 
249
- // Grava e commita o rascunho. O modo (actions|local) decide identidade do git e
250
- // o que fazer com falha de push — ver `lib/flow-run.mjs`.
251
- function commitFile(filePath, content, message, mode) {
252
- const published = commitGenerated({ filePath, content, message, mode });
252
+ /**
253
+ * Publica o documento em branch própria + Pull Request — ver lib/artifact-publish.mjs.
254
+ *
255
+ * ASSÍNCRONA, e todo chamador precisa de `await`. O `apply` chama isto dentro de
256
+ * um `try/catch` cuja única função é degradar a falha em aviso (as issues já
257
+ * existem; derrubar o run aqui mandaria o humano reaplicar o gatilho e duplicar
258
+ * dezenas de itens). Sem o `await`, a promise rejeitada não é capturada por esse
259
+ * catch: o aviso some e vira unhandled rejection.
260
+ */
261
+ async function publishFile(ctx, { doc, pathRel, content, nextLabel = null }) {
262
+ const { token, owner, repo, issue, issueNumber, base } = ctx;
263
+ const published = await publishArtifact({
264
+ token, owner, repo, doc,
265
+ issueNumber: parseInt(issueNumber, 10),
266
+ issueTitle: issue?.title || '',
267
+ issueUrl: issue?.html_url || '',
268
+ pathRel, content, base, nextLabel,
269
+ });
253
270
  if (published.warning) console.warn(`⚠️ ${published.warning}`);
271
+ return published;
254
272
  }
255
273
 
256
274
  // Os prompts vivem em `src/plugin/skills/decompose/model-prompt.{feature,rfc}.md`
@@ -263,23 +281,67 @@ function commitFile(filePath, content, message, mode) {
263
281
  // ---------------------------------------------------------------------------
264
282
 
265
283
  async function draftDecomposition(ctx) {
266
- const { token, owner, repo, issue, issueNumber, type, labels, usage, root, runMode, docDir, docPath, docRel } = ctx;
284
+ const {
285
+ token, owner, repo, issue, issueNumber, type, labels, usage, root, runMode,
286
+ docRel, dirRel, base,
287
+ } = ctx;
267
288
  const number = parseInt(issueNumber, 10);
268
289
  const kind = DECOMPOSE_TARGETS[type]; // Feature → 'stories'; RFC → 'tasks'
269
- const blobUrl = docBlobUrl({ owner, repo, pathRel: docRel, mode: runMode, root });
270
290
 
271
- const specPath = path.join(docDir, 'spec.md');
272
- const planPath = path.join(docDir, 'plan.md');
273
- const specContent = existsSync(specPath) ? readFileSync(specPath, 'utf-8') : null;
274
- const planContent = existsSync(planPath) ? readFileSync(planPath, 'utf-8') : null;
291
+ const ler = (doc, pathRel) => loadArtifact({
292
+ token, owner, repo, root, pathRel, doc, issueNumber: number, base,
293
+ });
294
+
295
+ // spec e plan alimentam o rascunho de Feature. Um deles preso num PR não
296
+ // mergeado com leitura ingênua viraria "(spec.md não encontrado)" no payload:
297
+ // rascunho gerado sobre o vazio, sem erro nenhum. Recusar é mais barato.
298
+ const spec = type === 'RFC' ? null : await ler('spec', `${dirRel}/spec.md`);
299
+ const plan = type === 'RFC' ? null : await ler('plan', `${dirRel}/plan.md`);
300
+ for (const [nome, doc] of [['spec.md', spec], ['plan.md', plan]]) {
301
+ if (doc && isAwaitingMerge(doc.state)) {
302
+ const bloqueio = awaitingMergeBlock({
303
+ pathRel: `${dirRel}/${nome}`, state: doc.state, pr: doc.pr, branch: doc.ref,
304
+ });
305
+ await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE).catch(() => {});
306
+ await commentOnIssue(token, owner, repo, number,
307
+ `⏸️ **decompose parado:** ${bloqueio.message}\n\n${bloqueio.unblock}\n\n` +
308
+ `Reaplique \`${LABEL_DECOMPOSE}\` depois do merge.`
309
+ ).catch(() => {});
310
+ throw new DecomposeBlockedError(bloqueio.message);
311
+ }
312
+ }
313
+ const specContent = spec?.content ?? null;
314
+ const planContent = plan?.content ?? null;
275
315
 
276
316
  // Rascunho existente é preservado COMO ESTÁ: o humano corrige o arquivo e
277
317
  // re-aplica a label para uma nova crítica. Regenerar aqui apagaria a correção
278
318
  // — é justamente o que fazia o ciclo não convergir.
319
+ //
320
+ // Com a publicação por Pull Request, "existente" deixou de ser `existsSync`: o
321
+ // rascunho que o revisor está editando mora na branch do PR. Ler só o disco
322
+ // aqui INVERTERIA a invariante — regeneraria por cima da revisão, e ainda
323
+ // pagaria a IA de novo.
324
+ const rascunho = await ler('decomposition', docRel);
325
+
279
326
  let markdown;
280
- if (existsSync(docPath)) {
281
- console.log(`Rascunho encontrado em ${docRel} — criticando o arquivo como está (sem regerar).`);
282
- markdown = readFileSync(docPath, 'utf-8');
327
+ let publicado = null;
328
+ if (rascunho.content != null) {
329
+ const onde = rascunho.state === 'pending-pr'
330
+ ? `no PR #${rascunho.pr?.number} (ainda não mergeado)`
331
+ : rascunho.state === 'branch-only'
332
+ ? `na branch ${rascunho.ref} (sem PR aberto)`
333
+ : docRel;
334
+ console.log(`Rascunho encontrado ${onde} — criticando o arquivo como está (sem regerar).`);
335
+ markdown = rascunho.content;
336
+ // O PR que já existe é o mesmo lugar onde o revisor vai corrigir: o
337
+ // comentário de fecho precisa apontá-lo, senão a re-crítica sai sem link e a
338
+ // pessoa fica procurando onde editar.
339
+ if (isAwaitingMerge(rascunho.state)) {
340
+ // `branch-only` entra aqui de propósito: republicar reaproveita a branch e
341
+ // TENTA abrir o PR de novo — é o caminho de recuperação quando a abertura
342
+ // falhou antes. O conteúdo é o do revisor, não um regerado.
343
+ publicado = { pr: rascunho.pr, branch: rascunho.ref, unchanged: true };
344
+ }
283
345
  } else {
284
346
  console.log(`Gerando rascunho de decomposição para ${type}: ${issue.title}`);
285
347
  const userContent = type === 'RFC'
@@ -312,10 +374,22 @@ async function draftDecomposition(ctx) {
312
374
  stories: generated.stories || [],
313
375
  tasks: generated.tasks || [],
314
376
  });
315
- commitFile(docPath, markdown, `docs: rascunho de decomposição de ${docRel} [spec-wave]`, runMode);
316
- console.log(`Rascunho commitado em ${docRel}.`);
377
+ publicado = await publishFile(ctx, {
378
+ doc: 'decomposition', pathRel: docRel, content: markdown,
379
+ nextLabel: LABEL_DECOMPOSE_APPLY,
380
+ });
381
+ console.log(`Rascunho publicado em ${publicado.branch} (${docRel}).`);
317
382
  }
318
383
 
384
+ // O link só pode ser montado DEPOIS de saber onde o documento está: na branch
385
+ // do PR recém-aberto, na do PR que já existia, ou na base. Inferir a ref pelo
386
+ // ambiente (o que o docBlobUrl faz por padrão) daria a branch default num
387
+ // evento `issues: labeled` — e lá o arquivo ainda não está.
388
+ const refDoDocumento = publicado?.branch || rascunho.ref || undefined;
389
+ const blobUrl = docBlobUrl({
390
+ owner, repo, pathRel: docRel, mode: runMode, root, ref: refDoDocumento,
391
+ });
392
+
319
393
  // Valida a estrutura ANTES de gastar tokens com a crítica: um arquivo quebrado
320
394
  // por edição humana precisa de mensagem clara, não de uma crítica sobre nada.
321
395
  let doc;
@@ -338,7 +412,7 @@ async function draftDecomposition(ctx) {
338
412
 
339
413
  // O RFC não tem spec/plan para auditar contra — a crítica não teria referência.
340
414
  if (kind !== 'stories') {
341
- await finishDraft(ctx, { doc, blobUrl, itemCount, critiqued: false });
415
+ return await finishDraft(ctx, { doc, blobUrl, itemCount, critiqued: false, publicado });
342
416
  return;
343
417
  }
344
418
 
@@ -422,26 +496,41 @@ async function draftDecomposition(ctx) {
422
496
  // Crítica limpa: remove o bloqueio anterior, que também zera o contador de
423
497
  // tentativas na próxima rodada (ver resolveCritiqueAttempt).
424
498
  await removeLabel(token, owner, repo, number, LABEL_CRITIQUE_FAILED).catch(() => {});
425
- await finishDraft(ctx, { doc, blobUrl, itemCount, critiqued: true });
499
+ return await finishDraft(ctx, { doc, blobUrl, itemCount, critiqued: true, publicado });
426
500
  }
427
501
 
428
502
  // Fecho comum do rascunho aprovado: libera para revisão humana e o apply.
429
- async function finishDraft({ token, owner, repo, issueNumber, docRel }, { blobUrl, itemCount, critiqued }) {
503
+ async function finishDraft(
504
+ { token, owner, repo, issueNumber, docRel }, { blobUrl, itemCount, critiqued, publicado }
505
+ ) {
430
506
  const number = parseInt(issueNumber, 10);
431
507
  await addLabel(token, owner, repo, number, LABEL_DECOMPOSE_READY);
432
508
  await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE);
509
+
510
+ // O apply LÊ o rascunho da branch base, então ele só roda depois do merge —
511
+ // dizer "crie as issues agora" sem essa ressalva manda o humano num passo que
512
+ // vai ser recusado.
513
+ const pr = publicado?.pr;
514
+ const revisao = pr?.number
515
+ ? `🔀 Pull Request: #${pr.number} — ${pr.url}\n\n` +
516
+ '**Nada foi criado ainda.** Revise (e edite, se quiser) o arquivo **no PR**, ' +
517
+ 'faça o merge e então crie as issues:\n'
518
+ : '**Nada foi criado ainda.** Revise (e edite, se quiser) o arquivo e então crie as issues:\n';
519
+
433
520
  await commentOnIssue(token, owner, repo, number,
434
521
  `📝 **Rascunho de decomposição pronto para revisão** (${itemCount}).\n\n` +
435
522
  `📄 Arquivo: [\`${docRel}\`](${blobUrl})\n\n` +
436
523
  (critiqued
437
524
  ? 'A crítica adversarial não encontrou contradições graves. '
438
525
  : '') +
439
- `**Nada foi criado ainda.** Revise (e edite, se quiser) o arquivo e então crie as issues:\n` +
526
+ revisao +
440
527
  `\`\`\`\ngh issue edit ${issueNumber} --add-label "${LABEL_DECOMPOSE_APPLY}"\n\`\`\`\n` +
441
528
  `Se preferir uma nova crítica depois de editar, reaplique \`${LABEL_DECOMPOSE}\` — ` +
442
- 'o arquivo é criticado como está, sem ser regerado. Para gerar outro do zero, apague-o.'
529
+ 'o arquivo é criticado como está, sem ser regerado. Para gerar outro do zero, ' +
530
+ 'feche o PR e apague a branch.'
443
531
  ).catch(err => console.warn(`Falha ao comentar o rascunho: ${err.message}`));
444
532
  console.log(`Rascunho liberado para revisão: ${itemCount}.`);
533
+ return { pr: pr || null };
445
534
  }
446
535
 
447
536
  // ---------------------------------------------------------------------------
@@ -449,14 +538,38 @@ async function finishDraft({ token, owner, repo, issueNumber, docRel }, { blobUr
449
538
  // ---------------------------------------------------------------------------
450
539
 
451
540
  async function applyDecomposition(ctx) {
452
- const { token, owner, repo, issueNumber, docPath, docRel } = ctx;
541
+ const { token, owner, repo, issueNumber, docRel, root, base } = ctx;
453
542
  const number = parseInt(issueNumber, 10);
454
543
 
455
- if (!existsSync(docPath)) {
544
+ const rascunho = await loadArtifact({
545
+ token, owner, repo, root, pathRel: docRel,
546
+ doc: 'decomposition', issueNumber: number, base,
547
+ });
548
+
549
+ // Rascunho ainda em Pull Request é recusa DURA, não aviso: aplicar criaria
550
+ // dezenas de issues a partir de um documento que ninguém aprovou, e o próprio
551
+ // arquivo passaria a afirmar `applied=<data>` enquanto o revisor ainda decide.
552
+ // O merge do PR É a aprovação humana.
553
+ // Vale para os DOIS estados fora da base. Cobrir só `pending-pr` deixaria o
554
+ // apply criar dezenas de issues a partir de um rascunho que está numa branch
555
+ // sem PR — ou seja, que ninguém teve como revisar.
556
+ if (isAwaitingMerge(rascunho.state)) {
557
+ const bloqueio = awaitingMergeBlock({
558
+ pathRel: docRel, state: rascunho.state, pr: rascunho.pr, branch: rascunho.ref,
559
+ });
560
+ await commentOnIssue(token, owner, repo, number,
561
+ `⏸️ **decompose-apply parado:** ${bloqueio.message}\n\n${bloqueio.unblock}\n\n` +
562
+ `Nenhuma issue foi criada. Reaplique \`${LABEL_DECOMPOSE_APPLY}\` depois do merge.`
563
+ ).catch(() => {});
564
+ await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE_APPLY).catch(() => {});
565
+ throw new DecomposeBlockedError(bloqueio.message);
566
+ }
567
+
568
+ if (rascunho.content == null) {
456
569
  await commentOnIssue(token, owner, repo, number,
457
570
  `❌ **Não há rascunho de decomposição para aplicar.**\n\n` +
458
571
  `Esperava encontrar \`${docRel}\`. Gere o rascunho primeiro:\n` +
459
- `\`\`\`\ngh issue edit ${issueNumber} --add-label "${LABEL_DECOMPOSE}"\n\`\`\``
572
+ `\`\`\`\ngh issue edit ${issueNumber} --add-label "${LABEL_DECOMPOSE}"\n\`\`\`\n`
460
573
  ).catch(() => {});
461
574
  await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE_APPLY).catch(() => {});
462
575
  throw new DecomposeBlockedError(`${docRel} não encontrado — aplique ${LABEL_DECOMPOSE} primeiro.`);
@@ -464,7 +577,7 @@ async function applyDecomposition(ctx) {
464
577
 
465
578
  let doc;
466
579
  try {
467
- doc = parseDecompositionDoc(readFileSync(docPath, 'utf-8'));
580
+ doc = parseDecompositionDoc(rascunho.content);
468
581
  } catch (err) {
469
582
  await commentOnIssue(token, owner, repo, number,
470
583
  `❌ **Não consegui ler o rascunho da decomposição.**\n\n` +
@@ -508,14 +621,19 @@ async function applyDecomposition(ctx) {
508
621
  // Depois da criação de propósito: se este commit falhar, as issues já existem
509
622
  // e o pior caso é o comportamento anterior (arquivo sem anotação), avisado no
510
623
  // log. O contrário — anotar antes e falhar na criação — inventaria issues.
624
+ //
625
+ // Vai para uma branch PRÓPRIA (`spec-wave/<n>-decompose-apply`), nunca por cima
626
+ // da branch do rascunho: aquela já foi mergeada (é pré-condição do apply), e
627
+ // reabri-la produziria um PR reintroduzindo estado antigo.
511
628
  try {
512
- commitFile(
513
- docPath,
514
- renderDecompositionDoc({ ...doc, appliedAt: new Date().toISOString() }),
515
- `docs: registra as issues criadas em ${docRel} [spec-wave]`,
516
- ctx.runMode,
517
- );
518
- console.log(`${docRel} anotado com as issues criadas.`);
629
+ const anotado = await publishFile(ctx, {
630
+ doc: 'decomposition-apply',
631
+ pathRel: docRel,
632
+ content: renderDecompositionDoc({ ...doc, appliedAt: new Date().toISOString() }),
633
+ });
634
+ console.log(anotado.pr?.number
635
+ ? `${docRel} anotado com as issues criadas — PR #${anotado.pr.number}.`
636
+ : `${docRel} anotado com as issues criadas em ${anotado.branch}.`);
519
637
  } catch (err) {
520
638
  console.warn(
521
639
  `⚠️ Issues criadas, mas ${docRel} não foi anotado (${err.message}). ` +
@@ -992,8 +1110,12 @@ export async function decompose({ issueNumber, apply }) {
992
1110
 
993
1111
  // Config do repo: raiz para os caminhos de documento e escalada da crítica.
994
1112
  const { config, root } = loadConfig();
995
- const { rel: docRel, dir: docDir } = resolveDocDir(root, issue, type);
996
- const docPath = path.join(docDir, DECOMPOSITION_FILE);
1113
+ const { rel: docRel } = resolveDocDir(root, issue, type);
1114
+
1115
+ // Branch base: é dela que os documentos são lidos quando não estão no clone, e
1116
+ // é contra ela que o PR do rascunho é aberto. Best-effort — sem ela a leitura
1117
+ // cai para o default da API, que é o mesmo lugar.
1118
+ const base = await getRepoDefaultBranch(token, owner, repo).catch(() => null);
997
1119
 
998
1120
  // Comentários só são necessários no rascunho (contador de tentativas).
999
1121
  let comments = [];
@@ -1010,7 +1132,9 @@ export async function decompose({ issueNumber, apply }) {
1010
1132
  const usageEntries = [];
1011
1133
  const ctx = {
1012
1134
  token, projectToken, owner, repo, issue, issueNumber, type, labels, comments,
1013
- root, runMode, docDir, docPath, docRel: `${docRel}/${DECOMPOSITION_FILE}`,
1135
+ root, runMode, docRel: `${docRel}/${DECOMPOSITION_FILE}`,
1136
+ dirRel: docRel,
1137
+ base,
1014
1138
  // Números das issues já criadas por este run. Compartilhado por referência
1015
1139
  // com o applyCtx: o catch externo precisa saber se houve criação para não
1016
1140
  // aconselhar um retry que duplicaria itens.
@@ -1024,8 +1148,11 @@ export async function decompose({ issueNumber, apply }) {
1024
1148
  };
1025
1149
 
1026
1150
  try {
1027
- if (mode === 'apply') await applyDecomposition(ctx);
1028
- else await draftDecomposition(ctx);
1151
+ // O desfecho volta ao chamador (o `run` usa o PR para parar a cadeia: o passo
1152
+ // seguinte lê o documento da base, então depende do merge).
1153
+ return mode === 'apply'
1154
+ ? await applyDecomposition(ctx)
1155
+ : await draftDecomposition(ctx);
1029
1156
  } catch (err) {
1030
1157
  // Paradas por decisão do fluxo já comentaram na issue; erros inesperados não.
1031
1158
  if (!err.blocked) {