@spec-wave/cli 0.20.0 → 0.23.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 (38) hide show
  1. package/bin/spec-wave.mjs +2 -2
  2. package/package.json +1 -1
  3. package/src/agent/anthropic-agent.mjs +3 -1
  4. package/src/agent/errors.mjs +59 -13
  5. package/src/agent/openrouter-agent.mjs +5 -1
  6. package/src/api/github-graphql.mjs +127 -7
  7. package/src/api/github-rest.mjs +9 -2
  8. package/src/commands/code-review.mjs +15 -4
  9. package/src/commands/decompose.mjs +172 -23
  10. package/src/commands/doctor.mjs +88 -8
  11. package/src/commands/move.mjs +58 -1
  12. package/src/commands/order.mjs +200 -7
  13. package/src/commands/qa.mjs +8 -2
  14. package/src/commands/repair-stage.mjs +6 -1
  15. package/src/commands/story.mjs +6 -1
  16. package/src/commands/task.mjs +6 -1
  17. package/src/commands/triage.mjs +4 -1
  18. package/src/commands/validate.mjs +39 -18
  19. package/src/config.mjs +47 -3
  20. package/src/lib/board.mjs +87 -3
  21. package/src/lib/bug-doc.mjs +71 -0
  22. package/src/lib/claude.mjs +23 -6
  23. package/src/lib/decomposition-doc.mjs +66 -14
  24. package/src/lib/dependencies.mjs +14 -4
  25. package/src/lib/implement-board.mjs +15 -11
  26. package/src/plugin/.claude-plugin/plugin.json +1 -1
  27. package/src/plugin/skills/decompose/SKILL.md +3 -3
  28. package/src/plugin/skills/order/SKILL.md +8 -4
  29. package/src/plugin/skills/ready/SKILL.md +1 -1
  30. package/src/templates/skill/SKILL.md +4 -4
  31. package/src/templates/workflows/code-review.yml +9 -2
  32. package/src/templates/workflows/critique.yml +19 -2
  33. package/src/templates/workflows/decompose.yml +19 -2
  34. package/src/templates/workflows/generate-bug.yml +19 -2
  35. package/src/templates/workflows/generate-plan.yml +19 -2
  36. package/src/templates/workflows/generate-spec.yml +19 -2
  37. package/src/templates/workflows/qa.yml +5 -1
  38. package/src/templates/workflows/validate.yml +19 -2
@@ -49,9 +49,16 @@ import {
49
49
 
50
50
  // Adiciona a issue ao board na Etapa ✅ Ready / Status Todo. Best-effort; a
51
51
  // Etapa nunca retrocede (advanceToStage não toca itens já adiante).
52
- async function moveToReady(token, project, etapaField, statusField, nodeId) {
52
+ //
53
+ // `itemType` é o tipo do que ACABOU de ser criado ('Story'/'Task'/'Feature') —
54
+ // aqui não há dúvida sobre ele, e sem essa passagem o Work Item Type das issues
55
+ // nascidas do apply ficava vazio para sempre (ver ensureWorkItemType).
56
+ async function moveToReady(token, project, fields, nodeId, itemType) {
53
57
  if (!project?.id) return;
54
- await advanceToStage(token, project, etapaField, statusField, nodeId, STAGE_READY, PROGRESS_TODO);
58
+ await advanceToStage(
59
+ token, project, fields.etapaField, fields.statusField, nodeId, STAGE_READY, PROGRESS_TODO,
60
+ { typeField: fields.typeField, itemType },
61
+ );
55
62
  }
56
63
 
57
64
  /**
@@ -196,6 +203,25 @@ export function shouldSkipDecompose({ labels = [], subIssues = [], type } = {})
196
203
  return { skip: false, reason: '' };
197
204
  }
198
205
 
206
+ /**
207
+ * Milestone que os filhos herdam do pai (função PURA — testável).
208
+ *
209
+ * Uma Story/Task nasce para entregar a Feature que a gerou: separá-las da release
210
+ * do pai faria a mesma entrega aparecer em dois grupos — e o milestone é o que o
211
+ * fluxo usa como release (é o milestone fechado que sinaliza o deploy). Mesma
212
+ * regra que a reprovação de QA já aplica ao registrar um bug filho.
213
+ *
214
+ * Devolve o NÚMERO (o que `createIssue` aceita) ou `undefined` quando o pai não
215
+ * tem milestone — aí não há release a herdar, e o filho nasce sem, como antes.
216
+ *
217
+ * @param {{ milestone?: { number?: number, title?: string } | null }} [issue]
218
+ * @returns {number|undefined}
219
+ */
220
+ export function resolveInheritedMilestone(issue) {
221
+ const number = issue?.milestone?.number;
222
+ return Number.isInteger(number) && number > 0 ? number : undefined;
223
+ }
224
+
199
225
  // Diretório do documento por tipo. Feature usa o mesmo docs/features/<slug> da
200
226
  // spec/plan; RFC ganha o seu, já que não passa por spec/plan.
201
227
  function resolveDocDir(root, issue, type) {
@@ -457,8 +483,8 @@ async function applyDecomposition(ctx) {
457
483
 
458
484
  // Campos do board só são resolvidos aqui: a etapa de rascunho não toca o board.
459
485
  // `strict`: no apply, board inalcançável ABORTA antes da primeira criação.
460
- const { project, etapaField, statusField } = await resolveBoard(ctx, { strict: true });
461
- const applyCtx = { ...ctx, project, etapaField, statusField };
486
+ const { project, etapaField, statusField, typeField } = await resolveBoard(ctx, { strict: true });
487
+ const applyCtx = { ...ctx, project, etapaField, statusField, typeField };
462
488
 
463
489
  // As falhas de board voltam em vez de subir na hora: a exceção precisa esperar
464
490
  // a contabilidade de labels abaixo. Lançar aqui deixaria o gatilho
@@ -495,7 +521,16 @@ async function applyDecomposition(ctx) {
495
521
 
496
522
  await addLabel(token, owner, repo, number, LABEL_DECOMPOSED)
497
523
  .catch(err => console.warn(`Falha ao aplicar a label ${LABEL_DECOMPOSED}: ${err.message}`));
498
- await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE_APPLY);
524
+ // Best-effort como as duas vizinhas: as issues já existem, e derrubar o run
525
+ // porque a REMOÇÃO de uma label falhou (404 de label já removida por um run
526
+ // concorrente, 5xx da API) transformava um apply concluído em vermelho — e o
527
+ // vermelho manda o humano reaplicar o gatilho, que é o caminho para duplicar
528
+ // dezenas de issues. A label que sobra é visível na issue e removível à mão.
529
+ await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE_APPLY)
530
+ .catch(err => console.warn(
531
+ `Decomposição aplicada, mas a label ${LABEL_DECOMPOSE_APPLY} não pôde ser removida ` +
532
+ `(${err.message}). Remova-a à mão: gh issue edit ${number} --remove-label "${LABEL_DECOMPOSE_APPLY}".`
533
+ ));
499
534
  await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE_READY).catch(() => {});
500
535
 
501
536
  // Por último, com as labels já consistentes: escrita de board incompleta
@@ -519,7 +554,10 @@ export function renderBoardFailures(failures) {
519
554
  `${linhas}\n\n` +
520
555
  `Motivo: \`${motivo}\`\n\n` +
521
556
  'Verifique o `GH_PROJECT_TOKEN` (scope `project` e acesso ao Project da organização) e ' +
522
- 'reposicione os itens com `npx @spec-wave/cli@latest repair-stage <issue>`.'
557
+ 'reposicione os itens com `npx @spec-wave/cli@latest repair-stage <issue>`.\n\n' +
558
+ 'O run termina em VERMELHO de propósito — a decomposição em si CONCLUIU. ' +
559
+ `Não reaplique \`${LABEL_DECOMPOSE_APPLY}\`: as issues acima já existem e o apply ` +
560
+ 'recomeça do zero; o que falta é só a Etapa, e quem repara isso é o `repair-stage`.'
523
561
  );
524
562
  }
525
563
 
@@ -541,9 +579,59 @@ function failIfBoardIncomplete(failures) {
541
579
  throw err;
542
580
  }
543
581
 
582
+ // Linha do comentário registrando o milestone herdado ('' quando não há).
583
+ function milestoneLine(issue, milestone) {
584
+ if (!milestone) return '';
585
+ return `🏁 Milestone herdado do pai: **${issue.milestone?.title ?? `#${milestone}`}**.\n\n`;
586
+ }
587
+
588
+ /**
589
+ * Resolve as dependências externas do rascunho ANTES de criar qualquer issue.
590
+ *
591
+ * `#N` só entra no documento se a issue JÁ existe (fase 1 da proposta), e é
592
+ * aqui que isso é cobrado. Falhar cedo é a única opção honesta: descobrir no
593
+ * meio do laço que a #412 não existe deixaria metade das Stories criadas e a
594
+ * outra metade não — o estado que o apply inteiro foi desenhado para evitar.
595
+ *
596
+ * `addBlockedBy` exige o DATABASE id da bloqueadora (não aceita number nem node
597
+ * id), e é por isso que resolver custa uma leitura por issue distinta.
598
+ *
599
+ * @returns {Promise<Map<number, {number:number, id:number, title:string}>>}
600
+ */
601
+ async function resolveExternalDeps({ token, owner, repo }, doc) {
602
+ const numeros = [...new Set(
603
+ (doc.stories || []).flatMap(s => s.dependsOnIssues || [])
604
+ )].sort((a, b) => a - b);
605
+ const resolvidas = new Map();
606
+ const faltando = [];
607
+ for (const n of numeros) {
608
+ try {
609
+ const issue = await getIssue(token, owner, repo, n);
610
+ resolvidas.set(n, { number: issue.number, id: issue.id, title: issue.title });
611
+ } catch (err) {
612
+ faltando.push(`#${n} (${err.status === 404 ? 'não existe' : err.message})`);
613
+ }
614
+ }
615
+ if (faltando.length > 0) {
616
+ throw new Error(
617
+ `o rascunho depende de issue(s) que não consegui ler: ${faltando.join(', ')}. ` +
618
+ 'Em "**Depende de:**", `#N` precisa ser uma issue que JÁ existe — corrija o ' +
619
+ 'decomposition.md (ou aplique antes a Feature que cria essa Story).'
620
+ );
621
+ }
622
+ return resolvidas;
623
+ }
624
+
544
625
  async function createStoriesFromDoc(ctx, doc) {
545
- const { token, projectToken, owner, repo, issue, issueNumber, project, etapaField, statusField, docRel } = ctx;
626
+ const { token, projectToken, owner, repo, issue, issueNumber, project, docRel } = ctx;
627
+ const fields = { etapaField: ctx.etapaField, statusField: ctx.statusField, typeField: ctx.typeField };
546
628
  const featureNodeId = issue.node_id;
629
+ // Herdado uma vez e reusado por todas as Stories e Tasks deste apply.
630
+ const milestone = resolveInheritedMilestone(issue);
631
+ if (milestone) console.log(`Milestone herdado da ${ctx.type}: ${issue.milestone?.title ?? `#${milestone}`}.`);
632
+ // Antes da primeira criação: rascunho que aponta para issue inexistente é
633
+ // erro de escrita, e o lugar de reprovar é aqui.
634
+ const externas = await resolveExternalDeps(ctx, doc);
547
635
  const created = [];
548
636
  const createdStories = []; // issues criadas, na ordem dos índices das stories
549
637
  const generatedTexts = []; // títulos+corpos para o lint de idioma final
@@ -560,12 +648,18 @@ async function createStoriesFromDoc(ctx, doc) {
560
648
  .filter(Boolean)
561
649
  .join('\n\n') || '_(sem descrição)_';
562
650
 
563
- // dependsOnvem validado pelo parser (0-based, para trás).
564
- const depIssues = story.dependsOn.map(idx => createdStories[idx]).filter(Boolean);
651
+ // Irmãs (índices validados pelo parser, sempre para trás) + externas (já
652
+ // resolvidas acima). As duas viram a MESMA linha no corpo e a mesma relação
653
+ // blocked_by: para quem lê a issue, a fronteira da Feature não existe.
654
+ const depIssues = [
655
+ ...story.dependsOn.map(idx => createdStories[idx]),
656
+ ...(story.dependsOnIssues || []).map(n => externas.get(n)),
657
+ ].filter(Boolean);
565
658
  const depLine = formatDependencyLine(depIssues.map(d => d.number));
566
659
  if (depLine) storyBody += `\n\n${depLine}`;
567
660
 
568
- const createdStory = await createIssue(token, owner, repo, storyTitle, storyBody, ['[STORY]']);
661
+ const createdStory = await createIssue(token, owner, repo, storyTitle, storyBody, ['[STORY]'], { milestone });
662
+ ctx.createdItems.push(createdStory.number);
569
663
  // Anota no doc em memória: é daqui que sai o `**Issue:** #N` gravado no
570
664
  // arquivo no fim do apply.
571
665
  story.issue = createdStory.number;
@@ -588,7 +682,7 @@ async function createStoriesFromDoc(ctx, doc) {
588
682
  console.warn(` Story #${createdStory.number} criada, mas falhou ao vincular à Feature: ${err.message}`);
589
683
  }
590
684
  try {
591
- await moveToReady(projectToken, project, etapaField, statusField, createdStory.nodeId);
685
+ await moveToReady(projectToken, project, fields, createdStory.nodeId, 'Story');
592
686
  } catch (err) {
593
687
  console.warn(` Falha ao mover story #${createdStory.number} para "${STAGE_READY}": ${err.message}`);
594
688
  boardFailures.push({ number: createdStory.number, kind: 'Story', reason: err.message });
@@ -598,7 +692,8 @@ async function createStoriesFromDoc(ctx, doc) {
598
692
  console.log(` Criando task: ${task.title}`);
599
693
  const taskTitle = `[TASK] ${task.title}`;
600
694
  const taskBody = `${task.body}\n\n_Story pai: ${createdStory.url}_`;
601
- const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]']);
695
+ const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]'], { milestone });
696
+ ctx.createdItems.push(createdTask.number);
602
697
  task.issue = createdTask.number;
603
698
  generatedTexts.push(taskTitle, taskBody);
604
699
  try {
@@ -607,7 +702,7 @@ async function createStoriesFromDoc(ctx, doc) {
607
702
  console.warn(` Task #${createdTask.number} criada, mas falhou ao vincular à Story: ${err.message}`);
608
703
  }
609
704
  try {
610
- await moveToReady(projectToken, project, etapaField, statusField, createdTask.nodeId);
705
+ await moveToReady(projectToken, project, fields, createdTask.nodeId, 'Task');
611
706
  } catch (err) {
612
707
  console.warn(` Falha ao mover task #${createdTask.number} para "${STAGE_READY}": ${err.message}`);
613
708
  boardFailures.push({ number: createdTask.number, kind: 'Task', reason: err.message });
@@ -616,8 +711,8 @@ async function createStoriesFromDoc(ctx, doc) {
616
711
  }
617
712
 
618
713
  try {
619
- await moveToReady(projectToken, project, etapaField, statusField, featureNodeId);
620
- if (project?.id && etapaField) console.log(`Feature movida para "${STAGE_READY}" no board.`);
714
+ await moveToReady(projectToken, project, fields, featureNodeId, ctx.type);
715
+ if (project?.id && fields.etapaField) console.log(`Feature movida para "${STAGE_READY}" no board.`);
621
716
  } catch (err) {
622
717
  console.warn(`Falha ao mover Feature para "${STAGE_READY}": ${err.message}`);
623
718
  boardFailures.push({ number: parseInt(issueNumber, 10), kind: 'Feature', reason: err.message });
@@ -630,6 +725,7 @@ async function createStoriesFromDoc(ctx, doc) {
630
725
  await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10),
631
726
  `🔀 **Decomposição aplicada!**\n\n` +
632
727
  `A partir de \`${docRel}\` foram criados ${created.length} stories e suas tasks:\n\n${list}\n\n` +
728
+ milestoneLine(issue, milestone) +
633
729
  posicionamento +
634
730
  formatItemsLintWarning(generatedTexts)
635
731
  ).catch(err => console.warn(`Falha ao comentar a decomposição: ${err.message}`));
@@ -685,8 +781,11 @@ async function commentStoryOrder({ token, owner, repo, issueNumber, doc, created
685
781
  }
686
782
 
687
783
  async function createTasksFromDoc(ctx, doc) {
688
- const { token, projectToken, owner, repo, issue, issueNumber, project, etapaField, statusField, docRel } = ctx;
784
+ const { token, projectToken, owner, repo, issue, issueNumber, project, docRel } = ctx;
785
+ const fields = { etapaField: ctx.etapaField, statusField: ctx.statusField, typeField: ctx.typeField };
689
786
  const parentNodeId = issue.node_id;
787
+ const milestone = resolveInheritedMilestone(issue);
788
+ if (milestone) console.log(`Milestone herdado do RFC: ${issue.milestone?.title ?? `#${milestone}`}.`);
690
789
  const created = [];
691
790
  const generatedTexts = [];
692
791
  const boardFailures = [];
@@ -695,7 +794,8 @@ async function createTasksFromDoc(ctx, doc) {
695
794
  console.log(`Criando task: ${task.title}`);
696
795
  const taskTitle = `[TASK] ${task.title}`;
697
796
  const taskBody = `${task.body}\n\n_RFC pai: ${issue.html_url || `#${issueNumber}`}_`;
698
- const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]']);
797
+ const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]'], { milestone });
798
+ ctx.createdItems.push(createdTask.number);
699
799
  task.issue = createdTask.number;
700
800
  created.push({ title: taskTitle, url: createdTask.url });
701
801
  generatedTexts.push(taskTitle, taskBody);
@@ -706,7 +806,7 @@ async function createTasksFromDoc(ctx, doc) {
706
806
  console.warn(` Task #${createdTask.number} criada, mas falhou ao vincular ao RFC: ${err.message}`);
707
807
  }
708
808
  try {
709
- await moveToReady(projectToken, project, etapaField, statusField, createdTask.nodeId);
809
+ await moveToReady(projectToken, project, fields, createdTask.nodeId, 'Task');
710
810
  } catch (err) {
711
811
  console.warn(` Falha ao mover task #${createdTask.number} para "${STAGE_READY}": ${err.message}`);
712
812
  boardFailures.push({ number: createdTask.number, kind: 'Task', reason: err.message });
@@ -720,6 +820,7 @@ async function createTasksFromDoc(ctx, doc) {
720
820
  await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10),
721
821
  `🔀 **Decomposição do RFC aplicada!**\n\n` +
722
822
  `A partir de \`${docRel}\` foram criadas ${created.length} tasks:\n\n${list}\n\n` +
823
+ milestoneLine(issue, milestone) +
723
824
  posicionamento +
724
825
  formatItemsLintWarning(generatedTexts)
725
826
  ).catch(err => console.warn(`Falha ao comentar a decomposição: ${err.message}`));
@@ -757,6 +858,7 @@ async function resolveBoard({ projectToken, root }, { strict = false } = {}) {
757
858
 
758
859
  let etapaField = null;
759
860
  let statusField = null;
861
+ let typeField = null;
760
862
  if (project?.id) {
761
863
  try {
762
864
  etapaField = await resolveField(projectToken, project, 'Etapa');
@@ -770,14 +872,58 @@ async function resolveBoard({ projectToken, root }, { strict = false } = {}) {
770
872
  if (strict) throw new BoardUnreachableError(`campo Status não resolvido (${err.message})`);
771
873
  console.warn(`Não foi possível resolver campo Status do board: ${err.message}`);
772
874
  }
875
+ // NUNCA strict: o tipo é organização, não visibilidade. Um board sem o campo
876
+ // "Work Item Type" continua sendo um board válido — sem Etapa, não.
877
+ try {
878
+ typeField = await resolveField(projectToken, project, 'Work Item Type');
879
+ } catch (err) {
880
+ console.warn(`Não foi possível resolver campo Work Item Type do board: ${err.message}`);
881
+ }
773
882
  }
774
- return { project, etapaField, statusField };
883
+ return { project, etapaField, statusField, typeField };
775
884
  }
776
885
 
777
886
  // ---------------------------------------------------------------------------
778
887
  // Entrada
779
888
  // ---------------------------------------------------------------------------
780
889
 
890
+ /**
891
+ * Comentário de falha do apply (função PURA).
892
+ *
893
+ * O conselho depende de UMA coisa: já existe issue criada por este run?
894
+ *
895
+ * • Não → o retry é seguro e é o que se quer: reaplique o gatilho.
896
+ * • Sim → o apply recomeça do zero, e o único freio é o guard de idempotência
897
+ * (label `spec-wave:decomposed` ou sub-issues do tipo-alvo). Se a falha foi
898
+ * ANTES de a label ser aplicada e o vínculo de sub-issue também não pegou,
899
+ * reaplicar duplica tudo o que a lista abaixo mostra. Mandar "adicione a
900
+ * label de novo" nesse estado — que é o que este comentário fazia sempre —
901
+ * é o conselho errado exatamente quando ele custa mais caro.
902
+ *
903
+ * @param {object} params
904
+ * @param {string} params.trigger label que disparou o run
905
+ * @param {string} params.message mensagem do erro
906
+ * @param {number[]} [params.createdItems] issues já criadas por este run
907
+ * @param {'apply'|'draft'} [params.mode]
908
+ */
909
+ export function renderApplyFailureComment({ trigger, message, createdItems = [], mode = 'apply' }) {
910
+ const cabecalho =
911
+ `❌ **Falha no decompose (${mode === 'apply' ? 'aplicação' : 'rascunho'}).**\n\n` +
912
+ `\`\`\`\n${message}\n\`\`\`\n\n`;
913
+ if (createdItems.length === 0) {
914
+ return cabecalho +
915
+ `A label \`${trigger}\` foi removida para destravar o gatilho — ` +
916
+ 'adicione-a de novo para tentar outra vez.';
917
+ }
918
+ const lista = createdItems.map(n => `#${n}`).join(', ');
919
+ return cabecalho +
920
+ `⚠️ **${createdItems.length} issue(s) JÁ foram criadas por este run:** ${lista}.\n\n` +
921
+ `A label \`${trigger}\` foi removida para destravar o gatilho, mas **não a reaplique sem ` +
922
+ 'conferir**: o apply recomeça do zero e, se o guard de idempotência não tiver pegado ' +
923
+ `(label \`${LABEL_DECOMPOSED}\` ou sub-issues do tipo-alvo), essas issues são recriadas. ` +
924
+ 'Complete o que falta à mão, ou apague as issues acima antes de tentar de novo.';
925
+ }
926
+
781
927
  export async function decompose({ issueNumber, apply = false }) {
782
928
  const token = await resolveToken();
783
929
  // PROJECT_TOKEN deve ter scope "project" para atualizar GitHub Projects v2.
@@ -858,6 +1004,10 @@ export async function decompose({ issueNumber, apply = false }) {
858
1004
  const ctx = {
859
1005
  token, projectToken, owner, repo, issue, issueNumber, type, labels, comments,
860
1006
  root, runMode, docDir, docPath, docRel: `${docRel}/${DECOMPOSITION_FILE}`,
1007
+ // Números das issues já criadas por este run. Compartilhado por referência
1008
+ // com o applyCtx: o catch externo precisa saber se houve criação para não
1009
+ // aconselhar um retry que duplicaria itens.
1010
+ createdItems: [],
861
1011
  escalationModel: config?.ai?.escalationModel || null,
862
1012
  maxCritiqueAttempts:
863
1013
  Number.isInteger(config?.ai?.maxCritiqueAttempts) && config.ai.maxCritiqueAttempts > 0
@@ -874,10 +1024,9 @@ export async function decompose({ issueNumber, apply = false }) {
874
1024
  if (!err.blocked) {
875
1025
  await removeLabel(token, owner, repo, number, trigger).catch(() => {});
876
1026
  await commentOnIssue(token, owner, repo, number,
877
- `❌ **Falha no decompose (${mode === 'apply' ? 'aplicação' : 'rascunho'}).**\n\n` +
878
- `\`\`\`\n${err.message}\n\`\`\`\n\n` +
879
- `A label \`${trigger}\` foi removida para destravar o gatilho — ` +
880
- 'adicione-a de novo para tentar outra vez.'
1027
+ renderApplyFailureComment({
1028
+ trigger, message: err.message, createdItems: ctx.createdItems, mode,
1029
+ })
881
1030
  ).catch(() => {});
882
1031
  }
883
1032
  throw err;
@@ -16,7 +16,8 @@ import { getProjectSnapshot, listSubIssues } from '../api/github-graphql.mjs';
16
16
  import {
17
17
  CONFIG_FILE, WORKFLOW_FILES, getProvider, DEFAULT_PROVIDER, STATUS_OPTIONS,
18
18
  RETIRED_STAGES, ALL_LABELS, allLabelsFor, LABEL_NEEDS_HUMAN, MODEL_LABEL_PREFIX,
19
- DEFAULT_MAX_CRITIQUE_ATTEMPTS, STAGE_TRACKS, AI_ACTIONS,
19
+ DEFAULT_MAX_CRITIQUE_ATTEMPTS, STAGE_TRACKS, AI_ACTIONS, recommendedModelAliases,
20
+ modelLabels,
20
21
  } from '../config.mjs';
21
22
  import { findConfigPath } from '../lib/project-root.mjs';
22
23
  import {
@@ -284,7 +285,10 @@ async function checkConfig(ctx) {
284
285
  * @param {object} params
285
286
  * @param {string[]} [params.boardStages] opções reais de Etapa no project
286
287
  * @param {string[]} [params.repoLabels] nomes das labels do repo
287
- * @returns {{ unknownStages: string[], missingStages: string[], orphanLabels: string[], missingLabels: string[] }}
288
+ * @returns {{ unknownStages: string[], missingStages: string[], orphanLabels: string[],
289
+ * missingLabels: string[], missingModelLabels: string[] }}
290
+ * `missingLabels` traz só as do fluxo; as de modelo saem separadas
291
+ * porque a orientação é outra (ver o uso em checkBoardHygiene).
288
292
  */
289
293
  /**
290
294
  * Os ids de opção do .spec-wave.json batem com os do Project? (função PURA)
@@ -314,6 +318,35 @@ export function inspectStageIds({ configOptions = null, boardOptions = null } =
314
318
  return { staleIds, checked: true };
315
319
  }
316
320
 
321
+ /**
322
+ * O que falta em `ai.modelAliases` para chegar ao conjunto recomendado (função PURA).
323
+ *
324
+ * Sem apelido nenhum, a label `spec-wave:model:<apelido>` não resolve nada: o
325
+ * override por issue — o jeito de reprocessar UMA Feature difícil num modelo
326
+ * mais forte sem mexer na config do repo inteiro — fica inerte, e o doctor só
327
+ * dizia que estava inerte, sem dizer o que colar para destravá-lo.
328
+ *
329
+ * Sugere o MERGE (configurado + faltante), nunca a substituição: apelido já
330
+ * configurado pode estar em uso numa issue aberta, e mandar sobrescrever o
331
+ * bloco quebraria essa issue em silêncio.
332
+ *
333
+ * @param {object} params
334
+ * @param {string} [params.provider] valor de `ai.provider`
335
+ * @param {object|null} [params.aliases] bloco `ai.modelAliases` atual
336
+ * @returns {{ missing: Array<{alias: string, model: string}>, snippet: string }}
337
+ */
338
+ export function suggestModelAliases({ provider = undefined, aliases = null } = {}) {
339
+ const recommended = recommendedModelAliases(provider);
340
+ const have = new Set(Object.keys(aliases || {}));
341
+ const missing = Object.entries(recommended)
342
+ .filter(([alias]) => !have.has(alias))
343
+ .map(([alias, model]) => ({ alias, model }));
344
+ if (missing.length === 0) return { missing, snippet: '' };
345
+ const merged = { ...(aliases || {}) };
346
+ for (const { alias, model } of missing) merged[alias] = model;
347
+ return { missing, snippet: `"modelAliases": ${JSON.stringify(merged, null, 2)}` };
348
+ }
349
+
317
350
  export function inspectBoardHygiene({ boardStages = null, repoLabels = null, modelAliases = null } = {}) {
318
351
  const canonical = STATUS_OPTIONS.map(s => s.name);
319
352
  const known = new Set(canonical);
@@ -342,10 +375,18 @@ export function inspectBoardHygiene({ boardStages = null, repoLabels = null, mod
342
375
  ? repoLabels.filter(n => n.startsWith('spec-wave:') && !knownLabels.has(n)
343
376
  && (modelAliases !== null || !n.startsWith(MODEL_LABEL_PREFIX)))
344
377
  : [];
345
- const missingLabels = repoLabels
346
- ? wantedLabels.map(l => l.name).filter(n => !repoLabels.includes(n))
378
+ // Ausentes saem em duas listas: uma label do fluxo que falta é um repo
379
+ // desatualizado; uma `spec-wave:model:<apelido>` que falta é um apelido
380
+ // configurado que NINGUÉM consegue usar — a label não existe para ser aplicada
381
+ // na issue, então o override de modelo por execução está morto na origem.
382
+ const allMissing = repoLabels
383
+ ? wantedLabels.filter(l => !repoLabels.includes(l.name)).map(l => l.name)
347
384
  : [];
348
- return { unknownStages, retiredStages, missingStages, orphanLabels, missingLabels };
385
+ const missingModelLabels = allMissing.filter(n => n.startsWith(MODEL_LABEL_PREFIX));
386
+ const missingLabels = allMissing.filter(n => !n.startsWith(MODEL_LABEL_PREFIX));
387
+ return {
388
+ unknownStages, retiredStages, missingStages, orphanLabels, missingLabels, missingModelLabels,
389
+ };
349
390
  }
350
391
 
351
392
  /**
@@ -407,7 +448,7 @@ async function checkBoardHygiene(ctx) {
407
448
  }
408
449
  }
409
450
 
410
- const { unknownStages, retiredStages, missingStages, orphanLabels, missingLabels } =
451
+ const { unknownStages, retiredStages, missingStages, orphanLabels, missingLabels, missingModelLabels } =
411
452
  inspectBoardHygiene({ boardStages, repoLabels, modelAliases: cfg?.ai?.modelAliases ?? null });
412
453
 
413
454
  const notes = [];
@@ -481,8 +522,32 @@ async function checkBoardHygiene(ctx) {
481
522
  status = 'warn';
482
523
  notes.push(`Labels do fluxo ausentes no repo: ${missingLabels.join(', ')} — rode \`update\`.`);
483
524
  }
484
- if (orphanLabels.length === 0 && missingLabels.length === 0) {
485
- notes.push(`Labels: ${ALL_LABELS.length} do fluxo presentes, nenhuma descontinuada.`);
525
+ // Apelido configurado sem label é uma promessa que o repo não cumpre: a skill
526
+ // manda aplicar `spec-wave:model:<apelido>` na issue e o `gh issue edit` falha
527
+ // com "label not found" — ou, pior, cria a label sem cor nem descrição.
528
+ if (missingModelLabels.length > 0) {
529
+ status = 'warn';
530
+ const specs = modelLabels(cfg?.ai?.modelAliases);
531
+ const exemplo = specs.find(l => l.name === missingModelLabels[0]);
532
+ notes.push(
533
+ `Labels de modelo ausentes no repo: ${missingModelLabels.join(', ')} — o apelido está em ` +
534
+ '`ai.modelAliases`, mas a label não existe, então não há como aplicá-la numa issue e o ' +
535
+ 'override de modelo por execução fica indisponível. Rode ' +
536
+ '`npx @spec-wave/cli@latest update` para criar todas' +
537
+ (exemplo
538
+ ? `, ou uma a uma: \`gh label create "${exemplo.name}" --color ${exemplo.color} ` +
539
+ `--description "${exemplo.description}"\`.`
540
+ : '.')
541
+ );
542
+ }
543
+ if (orphanLabels.length === 0 && missingLabels.length === 0 && missingModelLabels.length === 0) {
544
+ // Conta as de modelo à parte — dizer só "N do fluxo" omitia que as labels
545
+ // de apelido também foram conferidas.
546
+ const modelCount = modelLabels(cfg?.ai?.modelAliases).length;
547
+ notes.push(
548
+ `Labels: ${ALL_LABELS.length} do fluxo` +
549
+ `${modelCount > 0 ? ` + ${modelCount} de modelo` : ''} presentes, nenhuma descontinuada.`
550
+ );
486
551
  }
487
552
  } else {
488
553
  notes.push('Labels do repo não verificáveis agora (sem token ou sem acesso).');
@@ -595,11 +660,19 @@ async function checkAi(ctx) {
595
660
  const aliases = fileAi.modelAliases && Object.keys(fileAi.modelAliases).length > 0
596
661
  ? fileAi.modelAliases
597
662
  : null;
663
+ const suggestion = suggestModelAliases({ provider: provider.value, aliases });
664
+ // Onde colar e o que rodar depois: o bloco sozinho não cria as labels, e sem
665
+ // a label criada no repo o override continua sem existir para quem abre a issue.
666
+ const howTo = (
667
+ `Cole no bloco \`ai\` do ${CONFIG_FILE}, commite e rode ` +
668
+ '`npx @spec-wave/cli@latest update` para criar as labels correspondentes:'
669
+ );
598
670
  if (!aliases) {
599
671
  notes.push(
600
672
  `Apelidos de modelo: nenhum (\`ai.modelAliases\` ausente) — labels ` +
601
673
  `\`${MODEL_LABEL_PREFIX}<apelido>\` serão ignoradas.`
602
674
  );
675
+ notes.push(`Sugestão de apelidos para o provider ${provider.value}. ${howTo}\n${suggestion.snippet}`);
603
676
  } else {
604
677
  notes.push(
605
678
  `Apelidos de modelo (\`${MODEL_LABEL_PREFIX}<apelido>\`): ` +
@@ -616,6 +689,13 @@ async function checkAi(ctx) {
616
689
  `${wrongShape.map(([a, m]) => `${a}=${m}`).join(', ')}.`
617
690
  );
618
691
  }
692
+ if (suggestion.missing.length > 0) {
693
+ notes.push(
694
+ `Apelidos recomendados ainda não configurados: ` +
695
+ `${suggestion.missing.map(({ alias, model }) => `${alias}=${model}`).join(', ')}. ` +
696
+ `${howTo}\n${suggestion.snippet}`
697
+ );
698
+ }
619
699
  }
620
700
 
621
701
  // Saída estruturada da crítica: sem structured output confiável, a validação
@@ -21,6 +21,8 @@ import { loadConfig } from '../lib/project-root.mjs';
21
21
  import {
22
22
  CONFIG_FILE, PROGRESS_TODO, PROGRESS_IN_PROGRESS, PROGRESS_DONE,
23
23
  isManualStageType, MANUAL_STAGE_TYPES, isStageInTrack, STAGE_TRACKS,
24
+ STAGE_READY, LABEL_PLAN_APPROVED, LABEL_SPEC, LABEL_PLAN, LABEL_CRITIQUE_FAILED,
25
+ LABEL_NEEDS_HUMAN, allowsSpecPlan, labelNames,
24
26
  } from '../config.mjs';
25
27
 
26
28
  const PROGRESS_VALUES = [PROGRESS_TODO, PROGRESS_IN_PROGRESS, PROGRESS_DONE];
@@ -49,6 +51,51 @@ export function resolveProgressName(input) {
49
51
  };
50
52
  }
51
53
 
54
+ /**
55
+ * O que o board sabe e quem move à mão pode não saber (função PURA).
56
+ *
57
+ * `advanceToStage` já protege a ORDEM das etapas ("a Etapa nunca retrocede"),
58
+ * mas nada olhava os PORTÕES: mover uma Feature para ✅ Ready sem
59
+ * `spec-wave:plan-approved` põe na fila do time um item cuja validação não
60
+ * passou — e com `spec-wave:spec` ainda na issue, que é o rastro de uma reprova
61
+ * recente. Foi o que aconteceu com a #43.
62
+ *
63
+ * AVISA, não bloqueia: a decisão pode ser deliberada (documentos corrigidos à
64
+ * mão, validação que não se quer rodar de novo), e o comando existe justamente
65
+ * para os casos que o fluxo não cobre. O que não pode é ser silencioso.
66
+ *
67
+ * @param {object} params
68
+ * @param {string|null} params.type tipo do work item
69
+ * @param {string} params.stage etapa de destino
70
+ * @param {Array<string|{name:string}>} [params.labels] labels da issue
71
+ * @returns {string[]} avisos, na ordem de gravidade
72
+ */
73
+ export function readinessWarnings({ type, stage, labels = [] } = {}) {
74
+ if (stage !== STAGE_READY) return [];
75
+ const nomes = labelNames(labels);
76
+ const avisos = [];
77
+
78
+ // Bug, RFC e Spike não passam por spec/plan — cobrar plan-approved deles
79
+ // seria inventar um portão que o fluxo não tem.
80
+ if (allowsSpecPlan(type) && !nomes.includes(LABEL_PLAN_APPROVED)) {
81
+ avisos.push(
82
+ `não tem \`${LABEL_PLAN_APPROVED}\`: spec+plan não passaram pelo \`validate\`. ` +
83
+ `Para validar, aplique \`spec-wave:ready\` na issue.`
84
+ );
85
+ }
86
+ for (const gatilho of [LABEL_SPEC, LABEL_PLAN]) {
87
+ if (nomes.includes(gatilho)) {
88
+ avisos.push(`ainda tem \`${gatilho}\` — há geração de documento pendente ou uma reprova recente.`);
89
+ }
90
+ }
91
+ for (const portao of [LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN]) {
92
+ if (nomes.includes(portao)) {
93
+ avisos.push(`tem \`${portao}\` — a crítica reprovou e ninguém liberou.`);
94
+ }
95
+ }
96
+ return avisos;
97
+ }
98
+
52
99
  export async function move({ issue: issueArg, stage: stageArg, status: statusArg }) {
53
100
  const issueNumber = parseInt(String(issueArg).replace('#', ''), 10);
54
101
  if (!Number.isInteger(issueNumber) || issueNumber <= 0) {
@@ -126,6 +173,11 @@ export async function move({ issue: issueArg, stage: stageArg, status: statusArg
126
173
  );
127
174
  }
128
175
 
176
+ // Portões do fluxo: aviso antes de escrever, com a issue já em mãos.
177
+ for (const aviso of readinessWarnings({ type, stage, labels: issue.labels })) {
178
+ p.log.warn(`#${issueNumber} ${aviso}`);
179
+ }
180
+
129
181
  const { project, error: projectError } = loadProjectConfig({ cwd: root || process.cwd() });
130
182
  if (projectError) {
131
183
  p.log.error(`${projectError} — board não atualizado. Rode \`spec-wave init\` (ou \`spec-wave refresh --config\`).`);
@@ -134,11 +186,16 @@ export async function move({ issue: issueArg, stage: stageArg, status: statusArg
134
186
  }
135
187
  const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
136
188
  const statusField = await resolveField(token, project, 'Status').catch(() => null);
189
+ // O `move` também REPARA o Work Item Type quando ele está vazio (o `apply` não
190
+ // o escrevia, então há board com dezenas de itens sem tipo). Só no vazio, e
191
+ // mesmo quando a Etapa não avança — ver ensureWorkItemType.
192
+ const typeField = await resolveField(token, project, 'Work Item Type').catch(() => null);
137
193
 
138
194
  let moved;
139
195
  try {
140
196
  moved = await advanceToStage(
141
- token, project, etapaField, statusField, issue.node_id, stage, status);
197
+ token, project, etapaField, statusField, issue.node_id, stage, status,
198
+ { typeField, itemType: type });
142
199
  } catch (err) {
143
200
  p.log.error(`Falha ao atualizar o board: ${err.message}`);
144
201
  process.exitCode = 1;