@spec-wave/cli 0.20.0 → 0.21.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spec-wave/cli",
3
- "version": "0.20.0",
3
+ "version": "0.21.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": {
@@ -179,17 +179,74 @@ export async function getProjectSnapshot(token, projectId) {
179
179
  };
180
180
  }
181
181
 
182
- // Adiciona uma issue/PR (pelo node id do conteúdo) ao Project. Retorna o id do item criado.
183
- export async function addProjectItem(token, projectId, contentId) {
182
+ /**
183
+ * O item deste conteúdo que JÁ está no project (função de rede, sem mutação).
184
+ *
185
+ * Vai pelo conteúdo (`node(contentId).projectItems`), não pelos itens do
186
+ * project: um board com milhares de itens paginaria por todos eles para achar
187
+ * um; uma issue está em poucos projects.
188
+ *
189
+ * @returns {Promise<string|null>} id do item, ou null se não estiver no project
190
+ */
191
+ export async function findProjectItem(token, projectId, contentId) {
184
192
  const client = makeClient(token);
185
193
  const result = await client(`
186
- mutation AddItem($projectId: ID!, $contentId: ID!) {
187
- addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) {
188
- item { id }
194
+ query FindItem($contentId: ID!) {
195
+ node(id: $contentId) {
196
+ ... on Issue { projectItems(first: 50) { nodes { id project { id } } } }
197
+ ... on PullRequest { projectItems(first: 50) { nodes { id project { id } } } }
189
198
  }
190
199
  }
191
- `, { projectId, contentId });
192
- return result.addProjectV2ItemById.item.id;
200
+ `, { contentId });
201
+ const nodes = result?.node?.projectItems?.nodes || [];
202
+ return nodes.find(n => n?.project?.id === projectId)?.id ?? null;
203
+ }
204
+
205
+ /**
206
+ * Adiciona uma issue/PR (pelo node id do conteúdo) ao Project e devolve o id do
207
+ * item. IDEMPOTENTE: item já presente devolve o id existente.
208
+ *
209
+ * `addProjectV2ItemById` é documentado como idempotente, mas na prática responde
210
+ * `Content already exists in this project` quando o item entrou por outro
211
+ * caminho entre a leitura e a escrita — a automação nativa "auto-add" do
212
+ * project, ou um run concorrente do próprio spec-wave. Como esta é a PRIMEIRA
213
+ * chamada de advanceToStage(), o erro abortava tudo o que vinha depois: o item
214
+ * ficava no board com o Status que a automação do project escreve e SEM Etapa —
215
+ * exatamente o estado que some de todas as telas (caso da #281). Traduzir o erro
216
+ * numa busca pelo item existente é o que torna a escrita seguinte possível.
217
+ */
218
+ export async function addProjectItem(token, projectId, contentId) {
219
+ const client = makeClient(token);
220
+ try {
221
+ const result = await client(`
222
+ mutation AddItem($projectId: ID!, $contentId: ID!) {
223
+ addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) {
224
+ item { id }
225
+ }
226
+ }
227
+ `, { projectId, contentId });
228
+ return result.addProjectV2ItemById.item.id;
229
+ } catch (err) {
230
+ if (!isAlreadyInProjectError(err)) throw err;
231
+ const existing = await findProjectItem(token, projectId, contentId);
232
+ // Sem item correspondente, o erro não era duplicidade — devolvê-lo é mais
233
+ // honesto que um null que estouraria adiante como "field id inválido".
234
+ if (!existing) throw err;
235
+ return existing;
236
+ }
237
+ }
238
+
239
+ /**
240
+ * O erro é "esse conteúdo já está no project"? (função PURA)
241
+ *
242
+ * A mensagem vem de fora e pode mudar; o teste é por substring e não por
243
+ * igualdade justamente por isso. Errar para o lado de NÃO reconhecer só
244
+ * restaura o comportamento antigo (o erro sobe).
245
+ */
246
+ export function isAlreadyInProjectError(err) {
247
+ const msg = String(err?.message || '');
248
+ return /already exists in this project/i.test(msg)
249
+ || /content already exists/i.test(msg);
193
250
  }
194
251
 
195
252
  // Cria a relação de sub-issue nativa do GitHub (parent → child). Ambos os IDs
@@ -282,6 +282,9 @@ export async function codeReview({ prNumber, resolveOnly = false }) {
282
282
  // Resolve campos uma vez, reutiliza em todas as Features.
283
283
  const etapaField = await resolveField(projectToken, project, 'Etapa').catch(() => null);
284
284
  const statusField = await resolveField(projectToken, project, 'Status').catch(() => null);
285
+ // Repara o Work Item Type vazio de passagem (o apply nunca o escreveu).
286
+ // Só preenche o vazio e nunca derruba o movimento — ver ensureWorkItemType.
287
+ const typeField = await resolveField(projectToken, project, 'Work Item Type').catch(() => null);
285
288
 
286
289
  const seen = new Set();
287
290
  const updated = [];
@@ -312,7 +315,9 @@ export async function codeReview({ prNumber, resolveOnly = false }) {
312
315
  if (seen.has(n)) continue;
313
316
  seen.add(n);
314
317
  try {
315
- const moved = await advanceToStage(projectToken, project, etapaField, statusField, info.nodeId, DONE_STAGE, DONE_STATUS);
318
+ const moved = await advanceToStage(
319
+ projectToken, project, etapaField, statusField, info.nodeId, DONE_STAGE, DONE_STATUS,
320
+ { typeField, itemType: 'Task' });
316
321
  if (moved) {
317
322
  updated.push(`#${n} ${info.title} → ${DONE_STAGE}`);
318
323
  console.log(`#${n} → "${DONE_STAGE}" / Status "${DONE_STATUS}".`);
@@ -329,7 +334,9 @@ export async function codeReview({ prNumber, resolveOnly = false }) {
329
334
  if (seen.has(n)) continue;
330
335
  seen.add(n);
331
336
  try {
332
- const moved = await advanceToStage(projectToken, project, etapaField, statusField, info.nodeId, CODE_REVIEW_STAGE, TODO_STATUS);
337
+ const moved = await advanceToStage(
338
+ projectToken, project, etapaField, statusField, info.nodeId, CODE_REVIEW_STAGE, TODO_STATUS,
339
+ { typeField, itemType: 'Story' });
333
340
  if (moved) {
334
341
  updated.push(`#${n} ${info.title} → ${CODE_REVIEW_STAGE}`);
335
342
  console.log(`#${n} → "${CODE_REVIEW_STAGE}" / Status "${TODO_STATUS}".`);
@@ -346,7 +353,9 @@ export async function codeReview({ prNumber, resolveOnly = false }) {
346
353
  if (seen.has(n)) continue;
347
354
  seen.add(n);
348
355
  try {
349
- const moved = await advanceToStage(projectToken, project, etapaField, statusField, info.nodeId, CODE_REVIEW_STAGE, TODO_STATUS);
356
+ const moved = await advanceToStage(
357
+ projectToken, project, etapaField, statusField, info.nodeId, CODE_REVIEW_STAGE, TODO_STATUS,
358
+ { typeField, itemType: 'Bug' });
350
359
  if (moved) {
351
360
  updated.push(`#${n} ${info.title} → ${CODE_REVIEW_STAGE}`);
352
361
  console.log(`Bug #${n} → "${CODE_REVIEW_STAGE}" / Status "${TODO_STATUS}".`);
@@ -367,7 +376,9 @@ export async function codeReview({ prNumber, resolveOnly = false }) {
367
376
  console.log(`Feature #${feature.number} mantida em desenvolvimento — ainda há Stories pendentes (fora de "${CODE_REVIEW_STAGE}").`);
368
377
  } else if (!seen.has(feature.number)) {
369
378
  seen.add(feature.number);
370
- const moved = await advanceToStage(projectToken, project, etapaField, statusField, feature.nodeId, CODE_REVIEW_STAGE, TODO_STATUS);
379
+ const moved = await advanceToStage(
380
+ projectToken, project, etapaField, statusField, feature.nodeId, CODE_REVIEW_STAGE, TODO_STATUS,
381
+ { typeField, itemType: 'Feature' });
371
382
  if (moved) {
372
383
  updated.push(`#${feature.number} ${feature.title} (Feature) → ${CODE_REVIEW_STAGE}`);
373
384
  console.log(`Feature #${feature.number} → "${CODE_REVIEW_STAGE}" (todas as Stories concluídas).`);
@@ -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
  /**
@@ -457,8 +464,8 @@ async function applyDecomposition(ctx) {
457
464
 
458
465
  // Campos do board só são resolvidos aqui: a etapa de rascunho não toca o board.
459
466
  // `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 };
467
+ const { project, etapaField, statusField, typeField } = await resolveBoard(ctx, { strict: true });
468
+ const applyCtx = { ...ctx, project, etapaField, statusField, typeField };
462
469
 
463
470
  // As falhas de board voltam em vez de subir na hora: a exceção precisa esperar
464
471
  // a contabilidade de labels abaixo. Lançar aqui deixaria o gatilho
@@ -495,7 +502,16 @@ async function applyDecomposition(ctx) {
495
502
 
496
503
  await addLabel(token, owner, repo, number, LABEL_DECOMPOSED)
497
504
  .catch(err => console.warn(`Falha ao aplicar a label ${LABEL_DECOMPOSED}: ${err.message}`));
498
- await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE_APPLY);
505
+ // Best-effort como as duas vizinhas: as issues já existem, e derrubar o run
506
+ // porque a REMOÇÃO de uma label falhou (404 de label já removida por um run
507
+ // concorrente, 5xx da API) transformava um apply concluído em vermelho — e o
508
+ // vermelho manda o humano reaplicar o gatilho, que é o caminho para duplicar
509
+ // dezenas de issues. A label que sobra é visível na issue e removível à mão.
510
+ await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE_APPLY)
511
+ .catch(err => console.warn(
512
+ `Decomposição aplicada, mas a label ${LABEL_DECOMPOSE_APPLY} não pôde ser removida ` +
513
+ `(${err.message}). Remova-a à mão: gh issue edit ${number} --remove-label "${LABEL_DECOMPOSE_APPLY}".`
514
+ ));
499
515
  await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE_READY).catch(() => {});
500
516
 
501
517
  // Por último, com as labels já consistentes: escrita de board incompleta
@@ -519,7 +535,10 @@ export function renderBoardFailures(failures) {
519
535
  `${linhas}\n\n` +
520
536
  `Motivo: \`${motivo}\`\n\n` +
521
537
  '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>`.'
538
+ 'reposicione os itens com `npx @spec-wave/cli@latest repair-stage <issue>`.\n\n' +
539
+ 'O run termina em VERMELHO de propósito — a decomposição em si CONCLUIU. ' +
540
+ `Não reaplique \`${LABEL_DECOMPOSE_APPLY}\`: as issues acima já existem e o apply ` +
541
+ 'recomeça do zero; o que falta é só a Etapa, e quem repara isso é o `repair-stage`.'
523
542
  );
524
543
  }
525
544
 
@@ -542,7 +561,8 @@ function failIfBoardIncomplete(failures) {
542
561
  }
543
562
 
544
563
  async function createStoriesFromDoc(ctx, doc) {
545
- const { token, projectToken, owner, repo, issue, issueNumber, project, etapaField, statusField, docRel } = ctx;
564
+ const { token, projectToken, owner, repo, issue, issueNumber, project, docRel } = ctx;
565
+ const fields = { etapaField: ctx.etapaField, statusField: ctx.statusField, typeField: ctx.typeField };
546
566
  const featureNodeId = issue.node_id;
547
567
  const created = [];
548
568
  const createdStories = []; // issues criadas, na ordem dos índices das stories
@@ -566,6 +586,7 @@ async function createStoriesFromDoc(ctx, doc) {
566
586
  if (depLine) storyBody += `\n\n${depLine}`;
567
587
 
568
588
  const createdStory = await createIssue(token, owner, repo, storyTitle, storyBody, ['[STORY]']);
589
+ ctx.createdItems.push(createdStory.number);
569
590
  // Anota no doc em memória: é daqui que sai o `**Issue:** #N` gravado no
570
591
  // arquivo no fim do apply.
571
592
  story.issue = createdStory.number;
@@ -588,7 +609,7 @@ async function createStoriesFromDoc(ctx, doc) {
588
609
  console.warn(` Story #${createdStory.number} criada, mas falhou ao vincular à Feature: ${err.message}`);
589
610
  }
590
611
  try {
591
- await moveToReady(projectToken, project, etapaField, statusField, createdStory.nodeId);
612
+ await moveToReady(projectToken, project, fields, createdStory.nodeId, 'Story');
592
613
  } catch (err) {
593
614
  console.warn(` Falha ao mover story #${createdStory.number} para "${STAGE_READY}": ${err.message}`);
594
615
  boardFailures.push({ number: createdStory.number, kind: 'Story', reason: err.message });
@@ -599,6 +620,7 @@ async function createStoriesFromDoc(ctx, doc) {
599
620
  const taskTitle = `[TASK] ${task.title}`;
600
621
  const taskBody = `${task.body}\n\n_Story pai: ${createdStory.url}_`;
601
622
  const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]']);
623
+ ctx.createdItems.push(createdTask.number);
602
624
  task.issue = createdTask.number;
603
625
  generatedTexts.push(taskTitle, taskBody);
604
626
  try {
@@ -607,7 +629,7 @@ async function createStoriesFromDoc(ctx, doc) {
607
629
  console.warn(` Task #${createdTask.number} criada, mas falhou ao vincular à Story: ${err.message}`);
608
630
  }
609
631
  try {
610
- await moveToReady(projectToken, project, etapaField, statusField, createdTask.nodeId);
632
+ await moveToReady(projectToken, project, fields, createdTask.nodeId, 'Task');
611
633
  } catch (err) {
612
634
  console.warn(` Falha ao mover task #${createdTask.number} para "${STAGE_READY}": ${err.message}`);
613
635
  boardFailures.push({ number: createdTask.number, kind: 'Task', reason: err.message });
@@ -616,8 +638,8 @@ async function createStoriesFromDoc(ctx, doc) {
616
638
  }
617
639
 
618
640
  try {
619
- await moveToReady(projectToken, project, etapaField, statusField, featureNodeId);
620
- if (project?.id && etapaField) console.log(`Feature movida para "${STAGE_READY}" no board.`);
641
+ await moveToReady(projectToken, project, fields, featureNodeId, ctx.type);
642
+ if (project?.id && fields.etapaField) console.log(`Feature movida para "${STAGE_READY}" no board.`);
621
643
  } catch (err) {
622
644
  console.warn(`Falha ao mover Feature para "${STAGE_READY}": ${err.message}`);
623
645
  boardFailures.push({ number: parseInt(issueNumber, 10), kind: 'Feature', reason: err.message });
@@ -685,7 +707,8 @@ async function commentStoryOrder({ token, owner, repo, issueNumber, doc, created
685
707
  }
686
708
 
687
709
  async function createTasksFromDoc(ctx, doc) {
688
- const { token, projectToken, owner, repo, issue, issueNumber, project, etapaField, statusField, docRel } = ctx;
710
+ const { token, projectToken, owner, repo, issue, issueNumber, project, docRel } = ctx;
711
+ const fields = { etapaField: ctx.etapaField, statusField: ctx.statusField, typeField: ctx.typeField };
689
712
  const parentNodeId = issue.node_id;
690
713
  const created = [];
691
714
  const generatedTexts = [];
@@ -696,6 +719,7 @@ async function createTasksFromDoc(ctx, doc) {
696
719
  const taskTitle = `[TASK] ${task.title}`;
697
720
  const taskBody = `${task.body}\n\n_RFC pai: ${issue.html_url || `#${issueNumber}`}_`;
698
721
  const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]']);
722
+ ctx.createdItems.push(createdTask.number);
699
723
  task.issue = createdTask.number;
700
724
  created.push({ title: taskTitle, url: createdTask.url });
701
725
  generatedTexts.push(taskTitle, taskBody);
@@ -706,7 +730,7 @@ async function createTasksFromDoc(ctx, doc) {
706
730
  console.warn(` Task #${createdTask.number} criada, mas falhou ao vincular ao RFC: ${err.message}`);
707
731
  }
708
732
  try {
709
- await moveToReady(projectToken, project, etapaField, statusField, createdTask.nodeId);
733
+ await moveToReady(projectToken, project, fields, createdTask.nodeId, 'Task');
710
734
  } catch (err) {
711
735
  console.warn(` Falha ao mover task #${createdTask.number} para "${STAGE_READY}": ${err.message}`);
712
736
  boardFailures.push({ number: createdTask.number, kind: 'Task', reason: err.message });
@@ -757,6 +781,7 @@ async function resolveBoard({ projectToken, root }, { strict = false } = {}) {
757
781
 
758
782
  let etapaField = null;
759
783
  let statusField = null;
784
+ let typeField = null;
760
785
  if (project?.id) {
761
786
  try {
762
787
  etapaField = await resolveField(projectToken, project, 'Etapa');
@@ -770,14 +795,58 @@ async function resolveBoard({ projectToken, root }, { strict = false } = {}) {
770
795
  if (strict) throw new BoardUnreachableError(`campo Status não resolvido (${err.message})`);
771
796
  console.warn(`Não foi possível resolver campo Status do board: ${err.message}`);
772
797
  }
798
+ // NUNCA strict: o tipo é organização, não visibilidade. Um board sem o campo
799
+ // "Work Item Type" continua sendo um board válido — sem Etapa, não.
800
+ try {
801
+ typeField = await resolveField(projectToken, project, 'Work Item Type');
802
+ } catch (err) {
803
+ console.warn(`Não foi possível resolver campo Work Item Type do board: ${err.message}`);
804
+ }
773
805
  }
774
- return { project, etapaField, statusField };
806
+ return { project, etapaField, statusField, typeField };
775
807
  }
776
808
 
777
809
  // ---------------------------------------------------------------------------
778
810
  // Entrada
779
811
  // ---------------------------------------------------------------------------
780
812
 
813
+ /**
814
+ * Comentário de falha do apply (função PURA).
815
+ *
816
+ * O conselho depende de UMA coisa: já existe issue criada por este run?
817
+ *
818
+ * • Não → o retry é seguro e é o que se quer: reaplique o gatilho.
819
+ * • Sim → o apply recomeça do zero, e o único freio é o guard de idempotência
820
+ * (label `spec-wave:decomposed` ou sub-issues do tipo-alvo). Se a falha foi
821
+ * ANTES de a label ser aplicada e o vínculo de sub-issue também não pegou,
822
+ * reaplicar duplica tudo o que a lista abaixo mostra. Mandar "adicione a
823
+ * label de novo" nesse estado — que é o que este comentário fazia sempre —
824
+ * é o conselho errado exatamente quando ele custa mais caro.
825
+ *
826
+ * @param {object} params
827
+ * @param {string} params.trigger label que disparou o run
828
+ * @param {string} params.message mensagem do erro
829
+ * @param {number[]} [params.createdItems] issues já criadas por este run
830
+ * @param {'apply'|'draft'} [params.mode]
831
+ */
832
+ export function renderApplyFailureComment({ trigger, message, createdItems = [], mode = 'apply' }) {
833
+ const cabecalho =
834
+ `❌ **Falha no decompose (${mode === 'apply' ? 'aplicação' : 'rascunho'}).**\n\n` +
835
+ `\`\`\`\n${message}\n\`\`\`\n\n`;
836
+ if (createdItems.length === 0) {
837
+ return cabecalho +
838
+ `A label \`${trigger}\` foi removida para destravar o gatilho — ` +
839
+ 'adicione-a de novo para tentar outra vez.';
840
+ }
841
+ const lista = createdItems.map(n => `#${n}`).join(', ');
842
+ return cabecalho +
843
+ `⚠️ **${createdItems.length} issue(s) JÁ foram criadas por este run:** ${lista}.\n\n` +
844
+ `A label \`${trigger}\` foi removida para destravar o gatilho, mas **não a reaplique sem ` +
845
+ 'conferir**: o apply recomeça do zero e, se o guard de idempotência não tiver pegado ' +
846
+ `(label \`${LABEL_DECOMPOSED}\` ou sub-issues do tipo-alvo), essas issues são recriadas. ` +
847
+ 'Complete o que falta à mão, ou apague as issues acima antes de tentar de novo.';
848
+ }
849
+
781
850
  export async function decompose({ issueNumber, apply = false }) {
782
851
  const token = await resolveToken();
783
852
  // PROJECT_TOKEN deve ter scope "project" para atualizar GitHub Projects v2.
@@ -858,6 +927,10 @@ export async function decompose({ issueNumber, apply = false }) {
858
927
  const ctx = {
859
928
  token, projectToken, owner, repo, issue, issueNumber, type, labels, comments,
860
929
  root, runMode, docDir, docPath, docRel: `${docRel}/${DECOMPOSITION_FILE}`,
930
+ // Números das issues já criadas por este run. Compartilhado por referência
931
+ // com o applyCtx: o catch externo precisa saber se houve criação para não
932
+ // aconselhar um retry que duplicaria itens.
933
+ createdItems: [],
861
934
  escalationModel: config?.ai?.escalationModel || null,
862
935
  maxCritiqueAttempts:
863
936
  Number.isInteger(config?.ai?.maxCritiqueAttempts) && config.ai.maxCritiqueAttempts > 0
@@ -874,10 +947,9 @@ export async function decompose({ issueNumber, apply = false }) {
874
947
  if (!err.blocked) {
875
948
  await removeLabel(token, owner, repo, number, trigger).catch(() => {});
876
949
  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.'
950
+ renderApplyFailureComment({
951
+ trigger, message: err.message, createdItems: ctx.createdItems, mode,
952
+ })
881
953
  ).catch(() => {});
882
954
  }
883
955
  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
@@ -134,11 +134,16 @@ export async function move({ issue: issueArg, stage: stageArg, status: statusArg
134
134
  }
135
135
  const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
136
136
  const statusField = await resolveField(token, project, 'Status').catch(() => null);
137
+ // O `move` também REPARA o Work Item Type quando ele está vazio (o `apply` não
138
+ // o escrevia, então há board com dezenas de itens sem tipo). Só no vazio, e
139
+ // mesmo quando a Etapa não avança — ver ensureWorkItemType.
140
+ const typeField = await resolveField(token, project, 'Work Item Type').catch(() => null);
137
141
 
138
142
  let moved;
139
143
  try {
140
144
  moved = await advanceToStage(
141
- token, project, etapaField, statusField, issue.node_id, stage, status);
145
+ token, project, etapaField, statusField, issue.node_id, stage, status,
146
+ { typeField, itemType: type });
142
147
  } catch (err) {
143
148
  p.log.error(`Falha ao atualizar o board: ${err.message}`);
144
149
  process.exitCode = 1;
@@ -73,6 +73,9 @@ export async function qa({ prNumber }) {
73
73
 
74
74
  const etapaField = await resolveField(projectToken, project, 'Etapa').catch(() => null);
75
75
  const statusField = await resolveField(projectToken, project, 'Status').catch(() => null);
76
+ // Repara o Work Item Type vazio de passagem (o apply nunca o escreveu).
77
+ // Só preenche o vazio e nunca derruba o movimento — ver ensureWorkItemType.
78
+ const typeField = await resolveField(projectToken, project, 'Work Item Type').catch(() => null);
76
79
 
77
80
  const seen = new Set();
78
81
  const updated = [];
@@ -86,7 +89,8 @@ export async function qa({ prNumber }) {
86
89
  seen.add(issue.number);
87
90
  try {
88
91
  const moved = await advanceToStage(
89
- projectToken, project, etapaField, statusField, issue.node_id, QA_STAGE, TODO_STATUS);
92
+ projectToken, project, etapaField, statusField, issue.node_id, QA_STAGE, TODO_STATUS,
93
+ { typeField, itemType: 'Bug' });
90
94
  if (moved) {
91
95
  updated.push(`#${issue.number} ${issue.title} (bug)`);
92
96
  console.log(`Bug #${issue.number} → "${QA_STAGE}" / Status "${TODO_STATUS}".`);
@@ -104,7 +108,9 @@ export async function qa({ prNumber }) {
104
108
  seen.add(feature.number);
105
109
  try {
106
110
  // Avança para "🧪 QA" e reinicia o Status em "Todo" (nunca retrocede).
107
- const moved = await advanceToStage(projectToken, project, etapaField, statusField, feature.node_id, QA_STAGE, TODO_STATUS);
111
+ const moved = await advanceToStage(
112
+ projectToken, project, etapaField, statusField, feature.node_id, QA_STAGE, TODO_STATUS,
113
+ { typeField, itemType: 'Feature' });
108
114
  if (moved) {
109
115
  updated.push(`#${feature.number} ${feature.title}`);
110
116
  console.log(`Feature #${feature.number} → "${QA_STAGE}" / Status "${TODO_STATUS}".`);
@@ -164,6 +164,10 @@ export async function repairStage(issuesArg, stageArg, options = {}) {
164
164
  }
165
165
  const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
166
166
  const statusField = status ? await resolveField(token, project, 'Status').catch(() => null) : null;
167
+ // Reparar a Etapa e deixar o item sem tipo seria consertar metade: os mesmos
168
+ // itens que perderam a Etapa nasceram do apply, que também não escrevia o
169
+ // Work Item Type. Só preenche o vazio (ver ensureWorkItemType).
170
+ const typeField = await resolveField(token, project, 'Work Item Type').catch(() => null);
167
171
 
168
172
  // Lê tudo ANTES de escrever qualquer coisa: reparo parcial por issue
169
173
  // inexistente no meio da lista é o tipo de surpresa que ninguém quer aqui.
@@ -216,7 +220,8 @@ export async function repairStage(issuesArg, stageArg, options = {}) {
216
220
  for (const alvo of alvos) {
217
221
  try {
218
222
  const { from } = await setItemStage(
219
- token, project, etapaField, statusField, alvo.issue.node_id, stage, status);
223
+ token, project, etapaField, statusField, alvo.issue.node_id, stage, status,
224
+ { typeField, itemType: alvo.type });
220
225
  reparados += 1;
221
226
  p.log.success(`#${alvo.number}: ${from || '(sem etapa)'} → ${chalk.bold(stage)}${status ? ` / ${status}` : ''}`);
222
227
  await commentOnIssue(token, owner, repo, alvo.number, renderRepairComment({
@@ -81,11 +81,16 @@ export async function story({ action, issue: issueArg }) {
81
81
  }
82
82
  const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
83
83
  const statusField = await resolveField(token, project, 'Status').catch(() => null);
84
+ // Repara o Work Item Type vazio de passagem (o apply nunca o escreveu).
85
+ // Só preenche o vazio e nunca derruba o movimento — ver ensureWorkItemType.
86
+ const typeField = await resolveField(token, project, 'Work Item Type').catch(() => null);
84
87
 
85
88
  // 3. Avança para Code Review (Status reinicia em Todo ao trocar de etapa).
86
89
  let moved;
87
90
  try {
88
- moved = await advanceToStage(token, project, etapaField, statusField, issue.node_id, STAGE_CODE_REVIEW, PROGRESS_TODO);
91
+ moved = await advanceToStage(
92
+ token, project, etapaField, statusField, issue.node_id, STAGE_CODE_REVIEW, PROGRESS_TODO,
93
+ { typeField, itemType: type });
89
94
  } catch (err) {
90
95
  p.log.error(`Falha ao atualizar o board: ${err.message}`);
91
96
  process.exitCode = 1;
@@ -102,6 +102,9 @@ export async function task({ action, issue: issueArg }) {
102
102
  }
103
103
  const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
104
104
  const statusField = await resolveField(token, project, 'Status').catch(() => null);
105
+ // Repara o Work Item Type vazio de passagem (o apply nunca o escreveu).
106
+ // Só preenche o vazio e nunca derruba o movimento — ver ensureWorkItemType.
107
+ const typeField = await resolveField(token, project, 'Work Item Type').catch(() => null);
105
108
 
106
109
  // 3. start: garante que nenhuma task irmã (mesma Story) está In Progress.
107
110
  if (action === 'start') {
@@ -146,7 +149,9 @@ export async function task({ action, issue: issueArg }) {
146
149
 
147
150
  let moved;
148
151
  try {
149
- moved = await advanceToStage(token, project, etapaField, statusField, issue.node_id, target.stage, target.status);
152
+ moved = await advanceToStage(
153
+ token, project, etapaField, statusField, issue.node_id, target.stage, target.status,
154
+ { typeField, itemType: type });
150
155
  } catch (err) {
151
156
  p.log.error(`Falha ao atualizar o board: ${err.message}`);
152
157
  process.exitCode = 1;
@@ -163,8 +163,11 @@ async function moveToReady({ token, issueNumber, issue, root }) {
163
163
  try {
164
164
  const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
165
165
  const statusField = await resolveField(token, project, 'Status').catch(() => null);
166
+ // Triagem só trata Bug — o tipo aqui é certeza, não inferência de título.
167
+ const typeField = await resolveField(token, project, 'Work Item Type').catch(() => null);
166
168
  const moved = await advanceToStage(
167
- token, project, etapaField, statusField, issue.node_id, STAGE_READY, PROGRESS_TODO);
169
+ token, project, etapaField, statusField, issue.node_id, STAGE_READY, PROGRESS_TODO,
170
+ { typeField, itemType: 'Bug' });
168
171
  if (!moved) {
169
172
  p.log.info(`#${issueNumber} já está em ${STAGE_READY} ou etapa posterior — mantido.`);
170
173
  }
package/src/config.mjs CHANGED
@@ -43,6 +43,44 @@ export const AI_ACTIONS = ['spec', 'plan', 'decompose', 'critique', 'bug'];
43
43
  // repositório inteiro. Resolvido por resolveModelLabel() em src/lib/claude.mjs.
44
44
  export const MODEL_LABEL_PREFIX = 'spec-wave:model:';
45
45
 
46
+ // Apelidos de modelo SUGERIDOS pelo `doctor` quando o repo não tem
47
+ // `ai.modelAliases` (ou tem só parte deles). São os mesmos que o repo do
48
+ // spec-wave usa: um por família de modelo, para que a label
49
+ // `spec-wave:model:<apelido>` cubra "roda de novo num modelo mais forte" sem
50
+ // obrigar cada projeto a inventar a própria nomenclatura.
51
+ //
52
+ // O mapa é POR PROVIDER porque o formato do id não é o mesmo: a OpenRouter usa
53
+ // slug com "/" (anthropic/claude-opus-5) e a API da Anthropic usa o id nu
54
+ // (claude-opus-5). Sugerir o formato errado geraria exatamente o aviso de
55
+ // "apelido com formato incompatível" que o doctor emite na linha seguinte.
56
+ export const RECOMMENDED_MODEL_ALIASES = {
57
+ openrouter: {
58
+ opus: 'anthropic/claude-opus-5',
59
+ sonnet: 'anthropic/claude-sonnet-5',
60
+ haiku: 'anthropic/claude-haiku-4.5',
61
+ glm: 'z-ai/glm-5.2',
62
+ 'kimi-k3': 'moonshotai/kimi-k3',
63
+ 'gpt-sol': 'openai/gpt-5.6-sol',
64
+ 'gpt-luna': 'openai/gpt-5.6-luna',
65
+ },
66
+ // Só os modelos da própria Anthropic — os demais não existem nessa API.
67
+ anthropic: {
68
+ opus: 'claude-opus-5',
69
+ sonnet: 'claude-sonnet-5',
70
+ haiku: 'claude-haiku-4-5',
71
+ },
72
+ };
73
+
74
+ /**
75
+ * Apelidos recomendados para um provider (função PURA).
76
+ *
77
+ * @param {string} [provider] valor de `ai.provider`
78
+ * @returns {Record<string,string>}
79
+ */
80
+ export function recommendedModelAliases(provider) {
81
+ return RECOMMENDED_MODEL_ALIASES[provider] || RECOMMENDED_MODEL_ALIASES[DEFAULT_PROVIDER];
82
+ }
83
+
46
84
  // Quantas críticas seguidas podem reprovar a mesma issue antes de exigir revisão
47
85
  // humana. Na última tentativa a IA NÃO é chamada: o fluxo aplica
48
86
  // `spec-wave:needs-human` e para. Ajustável por `ai.maxCritiqueAttempts`.
package/src/lib/board.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  // Extraídos de code-review.mjs/qa.mjs para uso também pelos comandos de CLI
3
3
  // (task/story/order). Ver a distinção Etapa × Status em config.mjs.
4
4
  import { addProjectItem, setItemSingleSelect, getSingleSelectField, getItemSingleSelectValue } from '../api/github-graphql.mjs';
5
- import { CONFIG_FILE, STAGE_ORDER, STATUS_OPTIONS } from '../config.mjs';
5
+ import { CONFIG_FILE, STAGE_ORDER, STATUS_OPTIONS, WORK_ITEM_TYPES } from '../config.mjs';
6
6
  import { loadConfig } from './project-root.mjs';
7
7
 
8
8
  /**
@@ -116,6 +116,45 @@ export function resolveStageName(input) {
116
116
  return { stage: null, error: `Etapa "${input}" não existe. Use uma destas: ${list()}.` };
117
117
  }
118
118
 
119
+ /**
120
+ * Preenche o "Work Item Type" do item quando ele está VAZIO (best-effort).
121
+ *
122
+ * O campo existe no board desde o `init`, mas só o comando `issue` o escrevia:
123
+ * toda issue nascida do `decompose --apply` (dezenas por Feature) e todo item
124
+ * que entrou no board por outro caminho ficavam com o tipo em branco, e as
125
+ * telas que agrupam por Work Item Type os perdiam. Não é configuração faltando:
126
+ * os mesmos ids, project e token escrevem o campo sem falha quando alguém o faz
127
+ * à mão. O `move` também não o reparava, então nem passar pelo fluxo corrigia.
128
+ *
129
+ * Escreve só no vazio, de propósito: o tipo do título ("[STORY] …") é uma
130
+ * inferência, e sobrescrever um valor que um humano ajustou no board seria
131
+ * trocar um dado bom por um palpite. Por isso também roda ANTES do guard de
132
+ * avanço de Etapa — item já adiante não avança, mas continua merecendo o reparo.
133
+ *
134
+ * Nunca lança: campo ausente, opção inexistente ou falha de rede viram `false`.
135
+ * O tipo é informação de organização; derrubar por causa dele um comando que
136
+ * moveu a Etapa seria trocar o essencial pelo acessório.
137
+ *
138
+ * @returns {Promise<boolean>} true se escreveu o campo agora
139
+ */
140
+ export async function ensureWorkItemType(token, project, typeField, itemId, itemType) {
141
+ if (!typeField?.id || !itemType) return false;
142
+ if (!WORK_ITEM_TYPES.includes(itemType)) return false;
143
+ const optionId = typeField.options?.[itemType];
144
+ if (!optionId) return false;
145
+ try {
146
+ const current = await getItemSingleSelectValue(token, itemId, typeField.id);
147
+ if (current) return false; // já tem tipo — nunca sobrescreve
148
+ await setItemSingleSelect(token, project.id, itemId, typeField.id, optionId);
149
+ return true;
150
+ } catch (err) {
151
+ // Avisa em vez de calar: foi o silêncio que deixou 120 itens sem tipo por
152
+ // seis features seguidas sem ninguém perceber.
153
+ console.warn(`Work Item Type "${itemType}" não pôde ser escrito: ${err.message}`);
154
+ return false;
155
+ }
156
+ }
157
+
119
158
  /**
120
159
  * Avança um item do board para `targetStage` (Etapa) e define o Status para
121
160
  * `targetStatus`. Uma issue só AVANÇA: se já estiver em `targetStage` ou em uma
@@ -128,10 +167,18 @@ export function resolveStageName(input) {
128
167
  * @param {string} nodeId node id da issue
129
168
  * @param {string} targetStage nome da etapa de destino
130
169
  * @param {string} targetStatus valor do Status (Todo/In Progress/Done)
170
+ * @param {object} [opts]
171
+ * @param {{id,options}|null} [opts.typeField] campo "Work Item Type" (ver resolveField)
172
+ * @param {string} [opts.itemType] tipo do item ('Story', 'Task', …) — escrito
173
+ * apenas se o campo estiver vazio (ver ensureWorkItemType)
131
174
  * @returns {Promise<boolean>} true se avançou; false se já estava adiante
132
175
  */
133
- export async function advanceToStage(token, project, etapaField, statusField, nodeId, targetStage, targetStatus) {
176
+ export async function advanceToStage(
177
+ token, project, etapaField, statusField, nodeId, targetStage, targetStatus, opts = {}
178
+ ) {
134
179
  const itemId = await addProjectItem(token, project.id, nodeId);
180
+ // Antes do guard de avanço: item que não avança também precisa do reparo.
181
+ await ensureWorkItemType(token, project, opts.typeField, itemId, opts.itemType);
135
182
 
136
183
  if (etapaField?.id && targetStage) {
137
184
  // Nunca retroceder — a decisão vive em shouldAdvanceStage (pura, testada).
@@ -167,9 +214,12 @@ export async function advanceToStage(token, project, etapaField, statusField, no
167
214
  * @param {string} nodeId node id da issue
168
215
  * @param {string} targetStage etapa de destino (precisa existir em STAGE_ORDER)
169
216
  * @param {string} [targetStatus] valor do Status; omitido = não mexe no Status
217
+ * @param {object} [opts] mesmo `{ typeField, itemType }` de advanceToStage
170
218
  * @returns {Promise<{ from: string|null }>} etapa em que o item estava
171
219
  */
172
- export async function setItemStage(token, project, etapaField, statusField, nodeId, targetStage, targetStatus) {
220
+ export async function setItemStage(
221
+ token, project, etapaField, statusField, nodeId, targetStage, targetStatus, opts = {}
222
+ ) {
173
223
  if (!etapaField?.id) throw new Error('Campo "Etapa" não encontrado no Project — nada a reparar.');
174
224
  if (STAGE_ORDER.indexOf(targetStage) === -1) {
175
225
  throw new Error(`Etapa "${targetStage}" não faz parte do fluxo (${STAGE_ORDER.join(' → ')}).`);
@@ -182,6 +232,7 @@ export async function setItemStage(token, project, etapaField, statusField, node
182
232
  );
183
233
  }
184
234
  const itemId = await addProjectItem(token, project.id, nodeId);
235
+ await ensureWorkItemType(token, project, opts.typeField, itemId, opts.itemType);
185
236
  const from = await getItemSingleSelectValue(token, itemId, etapaField.id).catch(() => null);
186
237
  await setItemSingleSelect(token, project.id, itemId, etapaField.id, optionId);
187
238
  if (statusField?.id && targetStatus) {
@@ -22,8 +22,8 @@ import {
22
22
  * story?: {nodeId:string,number:number}|null,
23
23
  * bug?: {nodeId:string,number:number}|null,
24
24
  * tasks?: Array<{nodeId?:string,number:number}> }} refs
25
- * @returns {Array<{nodeId:string, label:string, stage:string, status:string,
26
- * statusFallback:boolean}>}
25
+ * @returns {Array<{nodeId:string, label:string, type:string, stage:string,
26
+ * status:string, statusFallback:boolean}>}
27
27
  * statusFallback: se a Etapa já estiver adiante (advanceToStage devolve
28
28
  * false), ainda assim alinhar o Status — usado nos movimentos de início
29
29
  * (ex.: Feature já em Desenvolvimento volta a mostrar In Progress).
@@ -37,37 +37,37 @@ export function planBoardMoves(phase, {
37
37
  const moves = [];
38
38
  if (phase === 'start') {
39
39
  if (feature?.nodeId) {
40
- moves.push({ nodeId: feature.nodeId, label: `Feature #${feature.number}`,
40
+ moves.push({ nodeId: feature.nodeId, label: `Feature #${feature.number}`, type: 'Feature',
41
41
  stage: STAGE_DEVELOPMENT, status: PROGRESS_IN_PROGRESS, statusFallback: true });
42
42
  }
43
43
  if (story?.nodeId) {
44
- moves.push({ nodeId: story.nodeId, label: `Story #${story.number}`,
44
+ moves.push({ nodeId: story.nodeId, label: `Story #${story.number}`, type: 'Story',
45
45
  stage: STAGE_DEVELOPMENT, status: PROGRESS_IN_PROGRESS, statusFallback: true });
46
46
  }
47
47
  // Bug é folha e não tem Feature-pai a arrastar: um defeito em correção não
48
48
  // deve puxar a Feature inteira de volta para Desenvolvimento.
49
49
  if (bug?.nodeId) {
50
- moves.push({ nodeId: bug.nodeId, label: `Bug #${bug.number}`,
50
+ moves.push({ nodeId: bug.nodeId, label: `Bug #${bug.number}`, type: 'Bug',
51
51
  stage: STAGE_DEVELOPMENT, status: PROGRESS_IN_PROGRESS, statusFallback: true });
52
52
  }
53
53
  for (const t of tasks) {
54
54
  if (!t?.nodeId) continue;
55
- moves.push({ nodeId: t.nodeId, label: `Task #${t.number}`,
55
+ moves.push({ nodeId: t.nodeId, label: `Task #${t.number}`, type: 'Task',
56
56
  stage: STAGE_DEVELOPMENT, status: tasksStartStatus,
57
57
  statusFallback: tasksStartStatus === PROGRESS_IN_PROGRESS });
58
58
  }
59
59
  } else if (phase === 'success') {
60
60
  for (const t of tasks) {
61
61
  if (!t?.nodeId) continue;
62
- moves.push({ nodeId: t.nodeId, label: `Task #${t.number}`,
62
+ moves.push({ nodeId: t.nodeId, label: `Task #${t.number}`, type: 'Task',
63
63
  stage: STAGE_DONE, status: PROGRESS_DONE, statusFallback: false });
64
64
  }
65
65
  if (story?.nodeId) {
66
- moves.push({ nodeId: story.nodeId, label: `Story #${story.number}`,
66
+ moves.push({ nodeId: story.nodeId, label: `Story #${story.number}`, type: 'Story',
67
67
  stage: STAGE_CODE_REVIEW, status: PROGRESS_TODO, statusFallback: false });
68
68
  }
69
69
  if (bug?.nodeId) {
70
- moves.push({ nodeId: bug.nodeId, label: `Bug #${bug.number}`,
70
+ moves.push({ nodeId: bug.nodeId, label: `Bug #${bug.number}`, type: 'Bug',
71
71
  stage: STAGE_CODE_REVIEW, status: PROGRESS_TODO, statusFallback: false });
72
72
  }
73
73
  // Feature: só o modo Feature passa `feature` aqui, e só depois de TODAS as
@@ -77,7 +77,7 @@ export function planBoardMoves(phase, {
77
77
  // num branch único e não abre PR), a Feature ficava presa em
78
78
  // Desenvolvimento com todas as Stories já em Code Review.
79
79
  if (feature?.nodeId) {
80
- moves.push({ nodeId: feature.nodeId, label: `Feature #${feature.number}`,
80
+ moves.push({ nodeId: feature.nodeId, label: `Feature #${feature.number}`, type: 'Feature',
81
81
  stage: STAGE_CODE_REVIEW, status: PROGRESS_TODO, statusFallback: false });
82
82
  }
83
83
  }
@@ -113,6 +113,9 @@ export async function applyBoardMoves({
113
113
  const projectToken = process.env.PROJECT_TOKEN || token;
114
114
  const etapaField = await resolveField(projectToken, project, 'Etapa').catch(() => null);
115
115
  const statusField = await resolveField(projectToken, project, 'Status').catch(() => null);
116
+ // Repara o Work Item Type vazio de passagem — o tipo de cada movimento é
117
+ // conhecido por construção (planBoardMoves). Ver ensureWorkItemType.
118
+ const typeField = await resolveField(projectToken, project, 'Work Item Type').catch(() => null);
116
119
  if (!etapaField?.id) {
117
120
  log.warn('board: campo Etapa não resolvido — Etapas não atualizadas.');
118
121
  return;
@@ -120,7 +123,8 @@ export async function applyBoardMoves({
120
123
  for (const m of moves) {
121
124
  try {
122
125
  const advanced = await advanceToStage(
123
- projectToken, project, etapaField, statusField, m.nodeId, m.stage, m.status);
126
+ projectToken, project, etapaField, statusField, m.nodeId, m.stage, m.status,
127
+ { typeField, itemType: m.type });
124
128
  if (advanced) {
125
129
  log.info(`board: ${m.label} → ${m.stage} (${m.status})`);
126
130
  } else if (m.statusFallback && statusField?.id) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "spec-wave",
3
3
  "displayName": "Spec Wave",
4
- "version": "0.20.0",
4
+ "version": "0.21.0",
5
5
  "description": "Fluxo spec-driven no GitHub (RFC-001): Projects v2, labels de gatilho, spec/plan gerados por Action, decomposição em duas etapas e implementação orientada a Stories/Tasks.",
6
6
  "author": {
7
7
  "name": "Astratech",
@@ -27,8 +27,12 @@ jobs:
27
27
  - uses: actions/setup-node@v4
28
28
  with:
29
29
  node-version: '24'
30
+
31
+ - name: Instala a CLI
32
+ run: npm install -g @spec-wave/cli@{{CLI_VERSION}}
33
+
30
34
  - id: resolve
31
- run: npx @spec-wave/cli@{{CLI_VERSION}} code-review --pr-number ${{ github.event.pull_request.number }} --resolve-only
35
+ run: spec-wave code-review --pr-number ${{ github.event.pull_request.number }} --resolve-only
32
36
  env:
33
37
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
34
38
  GITHUB_REPOSITORY: ${{ github.repository }}
@@ -56,8 +60,11 @@ jobs:
56
60
  with:
57
61
  node-version: '24'
58
62
 
63
+ - name: Instala a CLI
64
+ run: npm install -g @spec-wave/cli@{{CLI_VERSION}}
65
+
59
66
  - name: Move Feature to Code Review
60
- run: npx @spec-wave/cli@{{CLI_VERSION}} code-review --pr-number ${{ github.event.pull_request.number }}
67
+ run: spec-wave code-review --pr-number ${{ github.event.pull_request.number }}
61
68
  env:
62
69
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
63
70
  PROJECT_TOKEN: ${{ secrets.GH_PROJECT_TOKEN }}
@@ -5,7 +5,21 @@ on:
5
5
  types: [labeled]
6
6
 
7
7
  concurrency:
8
- group: spec-wave-critique-${{ github.event.issue.number }}
8
+ # Um run que NÃO vai fazer nada não pode disputar este grupo.
9
+ #
10
+ # `issues: [labeled]` dispara para QUALQUER label — prioridade, tipo,
11
+ # `spec-wave:model:*`. O job é filtrado pelo `if:` e termina skipped, mas o RUN
12
+ # entra na fila de concurrency do mesmo jeito, e com `cancel-in-progress: false`
13
+ # o GitHub cancela o run que já estava PENDENTE para pôr o novo no lugar. Foi
14
+ # assim que um decompose enfileirado atrás de um generate-plan morreu cancelado
15
+ # em 1 segundo por causa de uma label sem relação nenhuma — a label de gatilho
16
+ # fica na issue, `labeled` não redispara com ela aplicada, e o fluxo trava sem
17
+ # nenhum sinal. Rotular o run inútil com um grupo único (`github.run_id`) o tira
18
+ # da fila sem afrouxar a serialização de quem realmente vai rodar.
19
+ group: >-
20
+ ${{ github.event.label.name == 'spec-wave:critique'
21
+ && format('spec-wave-critique-{0}', github.event.issue.number)
22
+ || format('spec-wave-noop-{0}', github.run_id) }}
9
23
  cancel-in-progress: false
10
24
 
11
25
  jobs:
@@ -29,8 +43,11 @@ jobs:
29
43
  with:
30
44
  node-version: '24'
31
45
 
46
+ - name: Instala a CLI
47
+ run: npm install -g @spec-wave/cli@{{CLI_VERSION}}
48
+
32
49
  - name: Critique plan.md as-is
33
- run: npx @spec-wave/cli@{{CLI_VERSION}} critique --issue-number ${{ github.event.issue.number }}
50
+ run: spec-wave critique --issue-number ${{ github.event.issue.number }}
34
51
  env:
35
52
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
36
53
  ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
@@ -20,7 +20,21 @@ on:
20
20
  # o spec.md/plan.md do checkout — que uma geração concorrente estaria trocando
21
21
  # debaixo dele.
22
22
  concurrency:
23
- group: spec-wave-item-${{ github.event.issue.number }}
23
+ # Um run que NÃO vai fazer nada não pode disputar este grupo.
24
+ #
25
+ # `issues: [labeled]` dispara para QUALQUER label — prioridade, tipo,
26
+ # `spec-wave:model:*`. O job é filtrado pelo `if:` e termina skipped, mas o RUN
27
+ # entra na fila de concurrency do mesmo jeito, e com `cancel-in-progress: false`
28
+ # o GitHub cancela o run que já estava PENDENTE para pôr o novo no lugar. Foi
29
+ # assim que um decompose enfileirado atrás de um generate-plan morreu cancelado
30
+ # em 1 segundo por causa de uma label sem relação nenhuma — a label de gatilho
31
+ # fica na issue, `labeled` não redispara com ela aplicada, e o fluxo trava sem
32
+ # nenhum sinal. Rotular o run inútil com um grupo único (`github.run_id`) o tira
33
+ # da fila sem afrouxar a serialização de quem realmente vai rodar.
34
+ group: >-
35
+ ${{ (github.event.label.name == 'spec-wave:decompose' || github.event.label.name == 'spec-wave:decompose-apply')
36
+ && format('spec-wave-item-{0}', github.event.issue.number)
37
+ || format('spec-wave-noop-{0}', github.run_id) }}
24
38
  cancel-in-progress: false
25
39
 
26
40
  jobs:
@@ -50,9 +64,12 @@ jobs:
50
64
  with:
51
65
  node-version: '24'
52
66
 
67
+ - name: Instala a CLI
68
+ run: npm install -g @spec-wave/cli@{{CLI_VERSION}}
69
+
53
70
  - name: Decompose into Stories and Tasks
54
71
  run: >
55
- npx @spec-wave/cli@{{CLI_VERSION}} decompose
72
+ spec-wave decompose
56
73
  --issue-number ${{ github.event.issue.number }}
57
74
  ${{ github.event.label.name == 'spec-wave:decompose-apply' && '--apply' || '' }}
58
75
  env:
@@ -12,7 +12,21 @@ on:
12
12
  # diferentes a disputa é resolvida no laço de repetição de push do
13
13
  # lib/flow-run.mjs — serializar o repositório inteiro mataria a vazão.
14
14
  concurrency:
15
- group: spec-wave-item-${{ github.event.issue.number }}
15
+ # Um run que NÃO vai fazer nada não pode disputar este grupo.
16
+ #
17
+ # `issues: [labeled]` dispara para QUALQUER label — prioridade, tipo,
18
+ # `spec-wave:model:*`. O job é filtrado pelo `if:` e termina skipped, mas o RUN
19
+ # entra na fila de concurrency do mesmo jeito, e com `cancel-in-progress: false`
20
+ # o GitHub cancela o run que já estava PENDENTE para pôr o novo no lugar. Foi
21
+ # assim que um decompose enfileirado atrás de um generate-plan morreu cancelado
22
+ # em 1 segundo por causa de uma label sem relação nenhuma — a label de gatilho
23
+ # fica na issue, `labeled` não redispara com ela aplicada, e o fluxo trava sem
24
+ # nenhum sinal. Rotular o run inútil com um grupo único (`github.run_id`) o tira
25
+ # da fila sem afrouxar a serialização de quem realmente vai rodar.
26
+ group: >-
27
+ ${{ github.event.label.name == 'spec-wave:bug'
28
+ && format('spec-wave-item-{0}', github.event.issue.number)
29
+ || format('spec-wave-noop-{0}', github.run_id) }}
16
30
  cancel-in-progress: false
17
31
 
18
32
  jobs:
@@ -34,8 +48,11 @@ jobs:
34
48
  with:
35
49
  node-version: '24'
36
50
 
51
+ - name: Instala a CLI
52
+ run: npm install -g @spec-wave/cli@{{CLI_VERSION}}
53
+
37
54
  - name: Generate bug.md
38
- run: npx @spec-wave/cli@{{CLI_VERSION}} generate-bug --issue-number ${{ github.event.issue.number }}
55
+ run: spec-wave generate-bug --issue-number ${{ github.event.issue.number }}
39
56
  env:
40
57
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
41
58
  ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
@@ -20,7 +20,21 @@ on:
20
20
  # mataria a vazão, e a disputa entre issues diferentes é resolvida onde ela
21
21
  # acontece — no laço de repetição de push do lib/flow-run.mjs.
22
22
  concurrency:
23
- group: spec-wave-item-${{ github.event.issue.number }}
23
+ # Um run que NÃO vai fazer nada não pode disputar este grupo.
24
+ #
25
+ # `issues: [labeled]` dispara para QUALQUER label — prioridade, tipo,
26
+ # `spec-wave:model:*`. O job é filtrado pelo `if:` e termina skipped, mas o RUN
27
+ # entra na fila de concurrency do mesmo jeito, e com `cancel-in-progress: false`
28
+ # o GitHub cancela o run que já estava PENDENTE para pôr o novo no lugar. Foi
29
+ # assim que um decompose enfileirado atrás de um generate-plan morreu cancelado
30
+ # em 1 segundo por causa de uma label sem relação nenhuma — a label de gatilho
31
+ # fica na issue, `labeled` não redispara com ela aplicada, e o fluxo trava sem
32
+ # nenhum sinal. Rotular o run inútil com um grupo único (`github.run_id`) o tira
33
+ # da fila sem afrouxar a serialização de quem realmente vai rodar.
34
+ group: >-
35
+ ${{ github.event.label.name == 'spec-wave:plan'
36
+ && format('spec-wave-item-{0}', github.event.issue.number)
37
+ || format('spec-wave-noop-{0}', github.run_id) }}
24
38
  cancel-in-progress: false
25
39
 
26
40
  jobs:
@@ -42,8 +56,11 @@ jobs:
42
56
  with:
43
57
  node-version: '24'
44
58
 
59
+ - name: Instala a CLI
60
+ run: npm install -g @spec-wave/cli@{{CLI_VERSION}}
61
+
45
62
  - name: Generate plan.md
46
- run: npx @spec-wave/cli@{{CLI_VERSION}} generate-plan --issue-number ${{ github.event.issue.number }}
63
+ run: spec-wave generate-plan --issue-number ${{ github.event.issue.number }}
47
64
  env:
48
65
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
49
66
  ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
@@ -20,7 +20,21 @@ on:
20
20
  # mataria a vazão, e a disputa entre issues diferentes é resolvida onde ela
21
21
  # acontece — no laço de repetição de push do lib/flow-run.mjs.
22
22
  concurrency:
23
- group: spec-wave-item-${{ github.event.issue.number }}
23
+ # Um run que NÃO vai fazer nada não pode disputar este grupo.
24
+ #
25
+ # `issues: [labeled]` dispara para QUALQUER label — prioridade, tipo,
26
+ # `spec-wave:model:*`. O job é filtrado pelo `if:` e termina skipped, mas o RUN
27
+ # entra na fila de concurrency do mesmo jeito, e com `cancel-in-progress: false`
28
+ # o GitHub cancela o run que já estava PENDENTE para pôr o novo no lugar. Foi
29
+ # assim que um decompose enfileirado atrás de um generate-plan morreu cancelado
30
+ # em 1 segundo por causa de uma label sem relação nenhuma — a label de gatilho
31
+ # fica na issue, `labeled` não redispara com ela aplicada, e o fluxo trava sem
32
+ # nenhum sinal. Rotular o run inútil com um grupo único (`github.run_id`) o tira
33
+ # da fila sem afrouxar a serialização de quem realmente vai rodar.
34
+ group: >-
35
+ ${{ github.event.label.name == 'spec-wave:spec'
36
+ && format('spec-wave-item-{0}', github.event.issue.number)
37
+ || format('spec-wave-noop-{0}', github.run_id) }}
24
38
  cancel-in-progress: false
25
39
 
26
40
  jobs:
@@ -42,8 +56,11 @@ jobs:
42
56
  with:
43
57
  node-version: '24'
44
58
 
59
+ - name: Instala a CLI
60
+ run: npm install -g @spec-wave/cli@{{CLI_VERSION}}
61
+
45
62
  - name: Generate spec.md
46
- run: npx @spec-wave/cli@{{CLI_VERSION}} generate-spec --issue-number ${{ github.event.issue.number }}
63
+ run: spec-wave generate-spec --issue-number ${{ github.event.issue.number }}
47
64
  env:
48
65
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
49
66
  ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
@@ -24,8 +24,12 @@ jobs:
24
24
  with:
25
25
  node-version: '24'
26
26
 
27
+ - name: Instala a CLI
28
+ run: npm install -g @spec-wave/cli@{{CLI_VERSION}}
29
+
30
+
27
31
  - name: Move Feature to QA
28
- run: npx @spec-wave/cli@{{CLI_VERSION}} qa --pr-number ${{ github.event.pull_request.number }}
32
+ run: spec-wave qa --pr-number ${{ github.event.pull_request.number }}
29
33
  env:
30
34
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
31
35
  PROJECT_TOKEN: ${{ secrets.GH_PROJECT_TOKEN }}
@@ -5,7 +5,21 @@ on:
5
5
  types: [labeled]
6
6
 
7
7
  concurrency:
8
- group: spec-wave-validate-${{ github.event.issue.number }}
8
+ # Um run que NÃO vai fazer nada não pode disputar este grupo.
9
+ #
10
+ # `issues: [labeled]` dispara para QUALQUER label — prioridade, tipo,
11
+ # `spec-wave:model:*`. O job é filtrado pelo `if:` e termina skipped, mas o RUN
12
+ # entra na fila de concurrency do mesmo jeito, e com `cancel-in-progress: false`
13
+ # o GitHub cancela o run que já estava PENDENTE para pôr o novo no lugar. Foi
14
+ # assim que um decompose enfileirado atrás de um generate-plan morreu cancelado
15
+ # em 1 segundo por causa de uma label sem relação nenhuma — a label de gatilho
16
+ # fica na issue, `labeled` não redispara com ela aplicada, e o fluxo trava sem
17
+ # nenhum sinal. Rotular o run inútil com um grupo único (`github.run_id`) o tira
18
+ # da fila sem afrouxar a serialização de quem realmente vai rodar.
19
+ group: >-
20
+ ${{ github.event.label.name == 'spec-wave:ready'
21
+ && format('spec-wave-validate-{0}', github.event.issue.number)
22
+ || format('spec-wave-noop-{0}', github.run_id) }}
9
23
  cancel-in-progress: false
10
24
 
11
25
  jobs:
@@ -26,8 +40,11 @@ jobs:
26
40
  with:
27
41
  node-version: '24'
28
42
 
43
+ - name: Instala a CLI
44
+ run: npm install -g @spec-wave/cli@{{CLI_VERSION}}
45
+
29
46
  - name: Validate spec and plan
30
- run: npx @spec-wave/cli@{{CLI_VERSION}} validate --issue-number ${{ github.event.issue.number }}
47
+ run: spec-wave validate --issue-number ${{ github.event.issue.number }}
31
48
  env:
32
49
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
33
50
  GITHUB_REPOSITORY: ${{ github.repository }}