@spec-wave/cli 0.21.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.
package/bin/spec-wave.mjs CHANGED
@@ -263,8 +263,8 @@ program
263
263
 
264
264
  program
265
265
  .command('order')
266
- .description('Ordena as Stories de uma Feature pelas dependências (topológica)')
267
- .argument('<feature>', 'Número da issue da Feature, ex.: 12 ou #12')
266
+ .description('Ordena as Stories pelas dependências (topológica). Sem argumento, o mapa de todas as Features com trabalho')
267
+ .argument('[feature]', 'Número da issue da Feature, ex.: 12 ou #12. Omitido: todas as Features abertas fora de 🎉 Done')
268
268
  .action(async (feature) => {
269
269
  const { order } = await import('../src/commands/order.mjs');
270
270
  await order({ feature }).catch(err => { console.error(err.message); process.exit(1); });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spec-wave/cli",
3
- "version": "0.21.0",
3
+ "version": "0.23.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": {
@@ -25,7 +25,7 @@
25
25
  import path from 'node:path';
26
26
  import { withTrace, startObservation, updateSpan, endObservation } from './tracing.mjs';
27
27
  import { ENV_FILE_PATTERN } from './tools.mjs';
28
- import { summarizeToolCalls } from './errors.mjs';
28
+ import { summarizeToolCalls, toolSignature } from './errors.mjs';
29
29
  import { TruncatedOutputError } from './errors.mjs';
30
30
 
31
31
  const DEFAULT_TOOLS = ['Read', 'Glob', 'Grep'];
@@ -72,6 +72,7 @@ export async function runAnthropicAgent(prompt, options) {
72
72
  // Simétrico ao backend openrouter: sem isto, um run que estoura o teto de
73
73
  // turnos não deixa registro do que o modelo esteve fazendo.
74
74
  toolCalls: [],
75
+ toolSignatures: [],
75
76
  };
76
77
  const quiet = options.quiet !== false;
77
78
 
@@ -159,6 +160,7 @@ export async function runAnthropicAgent(prompt, options) {
159
160
  }
160
161
 
161
162
  runResult.toolCalls.push(input.tool_name);
163
+ runResult.toolSignatures.push(toolSignature(input.tool_name, input.tool_input));
162
164
  if (options.verbose) {
163
165
  console.log(`\n[Tool] ${input.tool_name}(${JSON.stringify(input.tool_input)})`);
164
166
  }
@@ -45,34 +45,80 @@ export class TruncatedOutputError extends Error {
45
45
  }
46
46
  }
47
47
 
48
+ /**
49
+ * Assinatura de uma tool call: nome + ALVO (função PURA).
50
+ *
51
+ * `Read×59` não distingue "leu 59 arquivos diferentes" de "leu o mesmo arquivo
52
+ * 59 vezes", e essas duas situações pedem remédios opostos. O alvo é o que
53
+ * separa as duas, e é o único dado do input que entra aqui — nada de conteúdo.
54
+ */
55
+ export function toolSignature(name, input) {
56
+ const alvo = input && typeof input === 'object'
57
+ ? input.file_path ?? input.path ?? input.pattern ?? input.query ?? input.command ?? null
58
+ : null;
59
+ return alvo ? `${name}:${String(alvo)}` : String(name);
60
+ }
61
+
62
+ /**
63
+ * O modelo estava EXPLORANDO (e não preso num loop)? (função PURA)
64
+ *
65
+ * Explorar = chamadas majoritariamente distintas: leu muitos arquivos, varreu
66
+ * muitos padrões e o orçamento acabou antes de escrever. Isso VARIA entre
67
+ * execuções — repetir a mesma requisição costuma passar. Loop degenerado é o
68
+ * contrário: a mesma chamada repetida, que repetir só encarece.
69
+ *
70
+ * Amostra pequena não sustenta nenhuma das duas conclusões, e o default é o
71
+ * conservador (não repetir).
72
+ */
73
+ export function looksLikeExploration(signatures = []) {
74
+ if (!Array.isArray(signatures) || signatures.length < 4) return false;
75
+ const distintas = new Set(signatures).size;
76
+ return distintas / signatures.length >= 0.5;
77
+ }
78
+
48
79
  // Teto de turnos esgotado: o modelo respondeu com tool calls em TODOS os turnos
49
- // e nunca produziu o documento. Como o truncamento acima, NÃO é transitório —
50
- // repetir a mesma requisição dá a mesma perambulação, só que mais cara.
80
+ // e nunca produziu o documento.
81
+ //
82
+ // NÃO é uma falha só, são duas, e a distinção é o que decide se repetir vale:
83
+ //
84
+ // • LOOP DEGENERADO (a mesma chamada de novo e de novo) é determinístico —
85
+ // repetir custou 55 minutos de Action num caso real: três tentativas de 25
86
+ // turnos (4min → 19min → 32min) para a mesma falha, que devia ter aparecido
87
+ // na primeira. Aqui não se repete: troca-se o modelo ou sobe-se o teto.
51
88
  //
52
- // Marcá-lo como transitório custou 55 minutos de Action num caso real: três
53
- // tentativas de 25 turnos (4min 19min 32min) para a mesma falha
54
- // determinística, que devia ter aparecido na primeira.
89
+ // EXPLORAÇÃO (chamadas distintas: Read×59 em 59 arquivos, Glob×20) é
90
+ // VARIÂNCIA, não limite estrutural o orçamento acabou antes de escrever.
91
+ // Tratar isso como determinístico mandava trocar de modelo por nada: em
92
+ // quatro ocorrências reais, três reaplicações da MESMA label no MESMO modelo
93
+ // passaram na segunda tentativa, uma delas depois de um run de 20min46s que
94
+ // uma repetição de ~2min teria evitado.
55
95
  export class MaxTurnsError extends Error {
56
- constructor({ provider, model, turns, action, toolCalls = [] }) {
57
- // O que o modelo ficou fazendo é a informação que decide o remédio: muitas
58
- // leituras distintas sugerem teto baixo; a mesma chamada repetida é loop
59
- // degenerado, e aí subir o teto só encarece.
96
+ constructor({ provider, model, turns, action, toolCalls = [], toolSignatures = [] }) {
60
97
  const resumo = toolCalls.length > 0
61
98
  ? ` Ferramentas mais chamadas: ${summarizeToolCalls(toolCalls)}.`
62
99
  : '';
100
+ const exploracao = looksLikeExploration(toolSignatures);
101
+ const remedio = exploracao
102
+ ? 'As chamadas são majoritariamente DISTINTAS — o modelo gastou o orçamento explorando, ' +
103
+ 'o que varia entre execuções. Uma repetição costuma bastar (a CLI já faz UMA, ' +
104
+ 'automaticamente); se insistir, suba `maxTurns` no prompt em vez de trocar o modelo.'
105
+ : 'As chamadas se REPETEM — é loop degenerado, e repetir reproduz a mesma perambulação ' +
106
+ 'mais cara. Troque o modelo (por `ai.models` ou pela label ' +
107
+ '`spec-wave:model:<apelido>`) ou suba o teto de turnos.';
63
108
  super(
64
109
  `O modelo esgotou o teto de ${turns} turnos sem produzir o documento ` +
65
110
  `(${provider} · ${model}${action ? ` · ação=${action}` : ''}): respondeu com chamadas de ` +
66
- `ferramenta em todos eles.${resumo} ` +
67
- 'Repetir não ajuda — o erro é determinístico. Ou o modelo não fecha o loop de ' +
68
- 'ferramentas nesta tarefa (troque-o, por `ai.models` ou pela label ' +
69
- '`spec-wave:model:<apelido>`), ou a exploração precisa de mais turnos.'
111
+ `ferramenta em todos eles.${resumo} ${remedio}`
70
112
  );
71
113
  this.name = 'MaxTurnsError';
72
114
  this.maxTurns = true;
115
+ // Quem decide o retry (lib/claude.mjs) lê esta flag — a classificação mora
116
+ // aqui, junto dos dados que a sustentam.
117
+ this.exploration = exploracao;
73
118
  this.turns = turns;
74
119
  this.action = action || null;
75
120
  this.toolCalls = toolCalls;
121
+ this.toolSignatures = toolSignatures;
76
122
  }
77
123
  }
78
124
 
@@ -22,7 +22,7 @@ import {
22
22
  toolDefinitionsFor,
23
23
  } from './tools.mjs';
24
24
  import { withTrace, startObservation, updateSpan, endObservation } from './tracing.mjs';
25
- import { TruncatedOutputError, isTruncationReason, summarizeToolCalls } from './errors.mjs';
25
+ import { TruncatedOutputError, isTruncationReason, summarizeToolCalls, toolSignature } from './errors.mjs';
26
26
 
27
27
  const DEFAULT_BASE_URL = 'https://openrouter.ai/api/v1';
28
28
  const DEFAULT_TOOLS = ['Read', 'Glob', 'Grep'];
@@ -184,6 +184,7 @@ export async function runOpenRouterAgent(prompt, options) {
184
184
  // turnos sem escrever nada, o log não dizia no que ele se perdeu, e o
185
185
  // diagnóstico exigiu reconstruir o run à mão.
186
186
  toolCalls: [],
187
+ toolSignatures: [],
187
188
  };
188
189
  const quiet = options.quiet !== false;
189
190
 
@@ -333,6 +334,9 @@ export async function runOpenRouterAgent(prompt, options) {
333
334
  }
334
335
 
335
336
  runResult.toolCalls.push(call.function.name);
337
+ // Assinatura (nome + alvo) separa exploração de loop degenerado —
338
+ // é o que decide se o teto de turnos merece uma repetição.
339
+ runResult.toolSignatures.push(toolSignature(call.function.name, parsedInput));
336
340
  if (options.verbose) console.log(`\n[Tool] ${call.function.name}(${call.function.arguments})`);
337
341
 
338
342
  const observation = startObservation(
@@ -249,6 +249,69 @@ export function isAlreadyInProjectError(err) {
249
249
  || /content already exists/i.test(msg);
250
250
  }
251
251
 
252
+ /**
253
+ * Todos os itens do Project, com os campos SINGLE_SELECT resolvidos por NOME.
254
+ *
255
+ * Uma query paginada no lugar de duas chamadas POR ITEM
256
+ * (`addProjectItem` + `getItemSingleSelectValue`), que é como o `order` lia a
257
+ * Etapa de cada Story. Com nove Features na tela isso passava de cem chamadas
258
+ * para montar um mapa que cabe em duas.
259
+ *
260
+ * Só itens cujo conteúdo é Issue entram (draft e PR ficam de fora).
261
+ *
262
+ * @returns {Promise<Array<{number, title, state, nodeId, itemId, fields: Record<string,string>}>>}
263
+ */
264
+ export async function listProjectItems(token, projectId) {
265
+ const client = makeClient(token);
266
+ const itens = [];
267
+ let after = null;
268
+ do {
269
+ const result = await client(`
270
+ query ProjectItems($id: ID!, $after: String) {
271
+ node(id: $id) {
272
+ ... on ProjectV2 {
273
+ items(first: 100, after: $after) {
274
+ pageInfo { hasNextPage endCursor }
275
+ nodes {
276
+ id
277
+ fieldValues(first: 20) {
278
+ nodes {
279
+ ... on ProjectV2ItemFieldSingleSelectValue {
280
+ name
281
+ field { ... on ProjectV2SingleSelectField { name } }
282
+ }
283
+ }
284
+ }
285
+ content { ... on Issue { number title state id } }
286
+ }
287
+ }
288
+ }
289
+ }
290
+ }
291
+ `, { id: projectId, after });
292
+ const page = result.node?.items;
293
+ for (const node of page?.nodes || []) {
294
+ const issue = node?.content;
295
+ if (!issue?.number) continue;
296
+ const fields = {};
297
+ for (const fv of node.fieldValues?.nodes || []) {
298
+ const nome = fv?.field?.name;
299
+ if (nome && fv.name) fields[nome] = fv.name;
300
+ }
301
+ itens.push({
302
+ number: issue.number,
303
+ title: issue.title,
304
+ state: issue.state,
305
+ nodeId: issue.id,
306
+ itemId: node.id,
307
+ fields,
308
+ });
309
+ }
310
+ after = page?.pageInfo?.hasNextPage ? page.pageInfo.endCursor : null;
311
+ } while (after);
312
+ return itens;
313
+ }
314
+
252
315
  // Cria a relação de sub-issue nativa do GitHub (parent → child). Ambos os IDs
253
316
  // são node ids de Issue. Faz com que o filho exiba o pai e vice-versa na UI.
254
317
  export async function addSubIssue(token, parentIssueId, childIssueId) {
@@ -281,9 +281,16 @@ export async function ensurePullRequest(
281
281
 
282
282
  // `id` é o database id da issue — exigido pela API de dependências
283
283
  // (blocked_by), que não aceita number nem node id.
284
- export async function createIssue(token, owner, repo, title, body, labels) {
284
+ //
285
+ // `milestone` é o NÚMERO do milestone no repo (não o título nem o node id) e só
286
+ // entra no payload quando existe: a API rejeita `milestone: null` com 422 em vez
287
+ // de tratar como "sem milestone".
288
+ export async function createIssue(token, owner, repo, title, body, labels, { milestone } = {}) {
285
289
  const octokit = makeOctokit(token);
286
- const res = await octokit.rest.issues.create({ owner, repo, title, body, labels });
290
+ const res = await octokit.rest.issues.create({
291
+ owner, repo, title, body, labels,
292
+ ...(milestone ? { milestone } : {}),
293
+ });
287
294
  return { number: res.data.number, nodeId: res.data.node_id, url: res.data.html_url, id: res.data.id };
288
295
  }
289
296
 
@@ -203,6 +203,25 @@ export function shouldSkipDecompose({ labels = [], subIssues = [], type } = {})
203
203
  return { skip: false, reason: '' };
204
204
  }
205
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
+
206
225
  // Diretório do documento por tipo. Feature usa o mesmo docs/features/<slug> da
207
226
  // spec/plan; RFC ganha o seu, já que não passa por spec/plan.
208
227
  function resolveDocDir(root, issue, type) {
@@ -560,10 +579,59 @@ function failIfBoardIncomplete(failures) {
560
579
  throw err;
561
580
  }
562
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
+
563
625
  async function createStoriesFromDoc(ctx, doc) {
564
626
  const { token, projectToken, owner, repo, issue, issueNumber, project, docRel } = ctx;
565
627
  const fields = { etapaField: ctx.etapaField, statusField: ctx.statusField, typeField: ctx.typeField };
566
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);
567
635
  const created = [];
568
636
  const createdStories = []; // issues criadas, na ordem dos índices das stories
569
637
  const generatedTexts = []; // títulos+corpos para o lint de idioma final
@@ -580,12 +648,17 @@ async function createStoriesFromDoc(ctx, doc) {
580
648
  .filter(Boolean)
581
649
  .join('\n\n') || '_(sem descrição)_';
582
650
 
583
- // dependsOnvem validado pelo parser (0-based, para trás).
584
- 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);
585
658
  const depLine = formatDependencyLine(depIssues.map(d => d.number));
586
659
  if (depLine) storyBody += `\n\n${depLine}`;
587
660
 
588
- const createdStory = await createIssue(token, owner, repo, storyTitle, storyBody, ['[STORY]']);
661
+ const createdStory = await createIssue(token, owner, repo, storyTitle, storyBody, ['[STORY]'], { milestone });
589
662
  ctx.createdItems.push(createdStory.number);
590
663
  // Anota no doc em memória: é daqui que sai o `**Issue:** #N` gravado no
591
664
  // arquivo no fim do apply.
@@ -619,7 +692,7 @@ async function createStoriesFromDoc(ctx, doc) {
619
692
  console.log(` Criando task: ${task.title}`);
620
693
  const taskTitle = `[TASK] ${task.title}`;
621
694
  const taskBody = `${task.body}\n\n_Story pai: ${createdStory.url}_`;
622
- const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]']);
695
+ const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]'], { milestone });
623
696
  ctx.createdItems.push(createdTask.number);
624
697
  task.issue = createdTask.number;
625
698
  generatedTexts.push(taskTitle, taskBody);
@@ -652,6 +725,7 @@ async function createStoriesFromDoc(ctx, doc) {
652
725
  await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10),
653
726
  `🔀 **Decomposição aplicada!**\n\n` +
654
727
  `A partir de \`${docRel}\` foram criados ${created.length} stories e suas tasks:\n\n${list}\n\n` +
728
+ milestoneLine(issue, milestone) +
655
729
  posicionamento +
656
730
  formatItemsLintWarning(generatedTexts)
657
731
  ).catch(err => console.warn(`Falha ao comentar a decomposição: ${err.message}`));
@@ -710,6 +784,8 @@ async function createTasksFromDoc(ctx, doc) {
710
784
  const { token, projectToken, owner, repo, issue, issueNumber, project, docRel } = ctx;
711
785
  const fields = { etapaField: ctx.etapaField, statusField: ctx.statusField, typeField: ctx.typeField };
712
786
  const parentNodeId = issue.node_id;
787
+ const milestone = resolveInheritedMilestone(issue);
788
+ if (milestone) console.log(`Milestone herdado do RFC: ${issue.milestone?.title ?? `#${milestone}`}.`);
713
789
  const created = [];
714
790
  const generatedTexts = [];
715
791
  const boardFailures = [];
@@ -718,7 +794,7 @@ async function createTasksFromDoc(ctx, doc) {
718
794
  console.log(`Criando task: ${task.title}`);
719
795
  const taskTitle = `[TASK] ${task.title}`;
720
796
  const taskBody = `${task.body}\n\n_RFC pai: ${issue.html_url || `#${issueNumber}`}_`;
721
- const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]']);
797
+ const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]'], { milestone });
722
798
  ctx.createdItems.push(createdTask.number);
723
799
  task.issue = createdTask.number;
724
800
  created.push({ title: taskTitle, url: createdTask.url });
@@ -744,6 +820,7 @@ async function createTasksFromDoc(ctx, doc) {
744
820
  await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10),
745
821
  `🔀 **Decomposição do RFC aplicada!**\n\n` +
746
822
  `A partir de \`${docRel}\` foram criadas ${created.length} tasks:\n\n${list}\n\n` +
823
+ milestoneLine(issue, milestone) +
747
824
  posicionamento +
748
825
  formatItemsLintWarning(generatedTexts)
749
826
  ).catch(err => console.warn(`Falha ao comentar a decomposição: ${err.message}`));
@@ -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\`).`);