@spec-wave/cli 0.28.0 → 0.30.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 (43) hide show
  1. package/package.json +1 -1
  2. package/src/api/github-graphql.mjs +37 -0
  3. package/src/api/github-rest.mjs +21 -0
  4. package/src/cli.mjs +73 -6
  5. package/src/commands/audit.mjs +280 -0
  6. package/src/commands/doctor.mjs +83 -2
  7. package/src/commands/generate-qa-plan.mjs +421 -0
  8. package/src/commands/implement.mjs +12 -0
  9. package/src/commands/merge.mjs +292 -0
  10. package/src/commands/move.mjs +26 -11
  11. package/src/commands/order.mjs +42 -0
  12. package/src/commands/qa-run.mjs +813 -0
  13. package/src/commands/run.mjs +9 -4
  14. package/src/config.mjs +17 -1
  15. package/src/lib/artifact-pr.mjs +2 -0
  16. package/src/lib/board.mjs +18 -2
  17. package/src/lib/critique.mjs +98 -13
  18. package/src/lib/decomposition-doc.mjs +5 -1
  19. package/src/lib/doc-paths.mjs +5 -2
  20. package/src/lib/next-step.mjs +15 -3
  21. package/src/lib/pr-step.mjs +12 -7
  22. package/src/lib/qa-exec.mjs +314 -0
  23. package/src/lib/qa-plan-doc.mjs +340 -0
  24. package/src/lib/qa-report.mjs +340 -0
  25. package/src/lib/spec-audit.mjs +372 -0
  26. package/src/lib/tech-context.mjs +20 -14
  27. package/src/plugin/.claude-plugin/plugin.json +1 -1
  28. package/src/plugin/skills/audit/SKILL.md +34 -0
  29. package/src/plugin/skills/audit/model-prompt.critique.md +34 -0
  30. package/src/plugin/skills/merge/SKILL.md +34 -0
  31. package/src/plugin/skills/order/SKILL.md +1 -0
  32. package/src/plugin/skills/plan/model-prompt.md +1 -0
  33. package/src/plugin/skills/plan/reference/tech-context.md +6 -0
  34. package/src/plugin/skills/preparar-feature/SKILL.md +3 -1
  35. package/src/plugin/skills/preparar-specs/SKILL.md +21 -1
  36. package/src/plugin/skills/preparar-specs/reference/revisao.md +5 -2
  37. package/src/plugin/skills/qa/SKILL.md +105 -0
  38. package/src/plugin/skills/qa/model-prompt.critique.md +44 -0
  39. package/src/plugin/skills/qa/model-prompt.md +68 -0
  40. package/src/templates/config/tech_context.yml +13 -0
  41. package/src/templates/skill/SKILL.md +77 -4
  42. package/src/templates/workflows/generate-qa-plan.yml +64 -0
  43. package/src/templates/workflows/qa.yml +9 -1
@@ -81,7 +81,7 @@ export function lockPath(root, key) {
81
81
  return path.join(gitCommonDir(root), 'spec-wave', `run-${key}.lock`);
82
82
  }
83
83
 
84
- function acquireLock(root, key) {
84
+ export function acquireLock(root, key) {
85
85
  const file = lockPath(root, key);
86
86
  mkdirSync(path.dirname(file), { recursive: true });
87
87
  const payload = JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() });
@@ -108,7 +108,7 @@ function acquireLock(root, key) {
108
108
  }
109
109
  }
110
110
 
111
- function releaseLock(file) {
111
+ export function releaseLock(file) {
112
112
  try {
113
113
  if (file) unlinkSync(file);
114
114
  } catch { /* já removido */ }
@@ -137,7 +137,7 @@ function localDocStates(issue, type, root) {
137
137
  const paths = featureDocPaths(root, issue, type);
138
138
  const docs = {};
139
139
  const docPaths = {};
140
- for (const nome of ['spec', 'plan', 'decomposition']) {
140
+ for (const nome of ['spec', 'plan', 'decomposition', 'qa-plan']) {
141
141
  docs[nome] = existsSync(paths[nome].abs) ? 'local' : 'missing';
142
142
  docPaths[nome] = paths[nome].rel;
143
143
  }
@@ -305,6 +305,10 @@ async function dispatch(action, { issueNumber }) {
305
305
  const { generateBug } = await import('./generate-bug.mjs');
306
306
  return await generateBug({ issueNumber });
307
307
  }
308
+ case 'generate-qa-plan': {
309
+ const { generateQaPlan } = await import('./generate-qa-plan.mjs');
310
+ return await generateQaPlan({ issueNumber });
311
+ }
308
312
  default:
309
313
  throw new Error(`Passo sem despacho: ${action}`);
310
314
  }
@@ -348,7 +352,8 @@ async function runForPr({ prNumber, dryRun, yes, only, json }) {
348
352
  process.exitCode = decision.blocked ? 2 : 0;
349
353
  return decision;
350
354
  }
351
- if (verdict.changesRequestedAfterApproval && !yes) {
355
+ // Mergeado, o pedido de mudanças pré-merge já foi decidido por quem mergeou.
356
+ if (verdict.changesRequestedAfterApproval && !pr.merged_at && !yes) {
352
357
  console.log(chalk.yellow('\n⛔ needs-confirmation: há pedido de mudanças além da aprovação. Confirme com `--yes`.'));
353
358
  process.exitCode = 2;
354
359
  return decision;
package/src/config.mjs CHANGED
@@ -54,7 +54,7 @@ export const DEFAULT_PROVIDER = 'anthropic';
54
54
  // Ações de IA que podem ter modelo próprio no .spec-wave.json (bloco
55
55
  // `ai.models`, ex.: { "critique": "claude-opus-4-1" }). Resolvidas em runtime
56
56
  // por resolveAiConfig() em src/lib/claude.mjs.
57
- export const AI_ACTIONS = ['spec', 'plan', 'decompose', 'critique', 'bug'];
57
+ export const AI_ACTIONS = ['spec', 'plan', 'decompose', 'critique', 'bug', 'qa'];
58
58
 
59
59
  // Override de modelo POR EXECUÇÃO: a label `spec-wave:model:<apelido>` na issue
60
60
  // aponta para uma entrada de `ai.modelAliases` do .spec-wave.json. Serve para
@@ -346,6 +346,17 @@ export const LABEL_DEV_AGENT = 'spec-wave:dev-agent';
346
346
  export const LABEL_BUG = 'spec-wave:bug';
347
347
  export const LABEL_BUG_APPROVED = 'spec-wave:bug-approved';
348
348
 
349
+ // Fluxo de QA (rfc/spec-qa-skill.md). O gatilho gera (ou re-critica) o
350
+ // qa-plan.md; as duas de estado são gravadas pelas automações, nunca à mão.
351
+ //
352
+ // ⚠️ `spec-wave:qa-ready` NÃO é `spec-wave:ready`: a primeira é ESTADO do plano
353
+ // de QA (passou na crítica, espera revisão humana — o portão do D-QA4); a
354
+ // segunda é o GATILHO da validação de spec/plan. Nenhuma checagem pode usar
355
+ // prefixo (`startsWith('spec-wave:ready')`) para distinguir as duas.
356
+ export const LABEL_QA = 'spec-wave:qa';
357
+ export const LABEL_QA_READY = 'spec-wave:qa-ready';
358
+ export const LABEL_QA_APPROVED = 'spec-wave:qa-approved';
359
+
349
360
  // Desfecho da triagem do PM (RFC-004 §4.1). São mutuamente exclusivas: um bug
350
361
  // triado foi aceito, rejeitado ou marcado como duplicata.
351
362
  export const LABEL_TRIAGED = 'spec-wave:triaged';
@@ -432,6 +443,9 @@ export const TRIGGER_LABELS = [
432
443
  { name: LABEL_DEV_AGENT, color: '5319E7', description: 'Enfileira a issue para o dev-agent autônomo' },
433
444
  { name: LABEL_BUG, color: 'BFD4F2', description: 'Gerar bug.md via GitHub Action' },
434
445
  { name: LABEL_BUG_APPROVED, color: '0E8A16', description: 'bug.md validado (reprodução, causa raiz e teste de regressão)' },
446
+ { name: LABEL_QA, color: 'BFD4F2', description: 'Gerar/re-criticar o plano de QA (qa-plan.md) via GitHub Action' },
447
+ { name: LABEL_QA_READY, color: '0E8A16', description: 'Plano de QA aprovado pela crítica — revise-o e rode `spec-wave qa <n>`' },
448
+ { name: LABEL_QA_APPROVED, color: '0E8A16', description: 'Execução do QA passou em todos os cenários' },
435
449
  { name: LABEL_TRIAGED, color: '0E8A16', description: 'Bug triado pelo PM (severidade, origem e pai definidos)' },
436
450
  { name: LABEL_DUPLICATE, color: 'EDEDED', description: 'Duplicata de outra issue (o corpo aponta qual)' },
437
451
  { name: LABEL_WONT_FIX, color: 'EDEDED', description: 'Rejeitado na triagem — não será corrigido' },
@@ -515,6 +529,7 @@ export const WORKFLOW_FILES = [
515
529
  'generate-spec.yml',
516
530
  'validate.yml',
517
531
  'decompose.yml',
532
+ 'generate-qa-plan.yml',
518
533
  'code-review.yml',
519
534
  'qa.yml',
520
535
  ];
@@ -535,6 +550,7 @@ export const ARTIFACT_WORKFLOW_FILES = [
535
550
  'generate-plan.yml',
536
551
  'generate-bug.yml',
537
552
  'decompose.yml',
553
+ 'generate-qa-plan.yml',
538
554
  ];
539
555
 
540
556
  export const ISSUE_TEMPLATE_FILES = [
@@ -34,6 +34,7 @@ export const ARTIFACT_DOCS = {
34
34
  bug: { suffix: 'bug', file: 'bug.md' },
35
35
  decomposition: { suffix: 'decompose', file: 'decomposition.md' },
36
36
  'decomposition-apply': { suffix: 'decompose-apply', file: 'decomposition.md' },
37
+ 'qa-plan': { suffix: 'qa-plan', file: 'qa-plan.md' },
37
38
  };
38
39
 
39
40
  /** Nomes aceitos em `doc`, na ordem do fluxo. */
@@ -102,6 +103,7 @@ const DOC_LABEL = {
102
103
  bug: 'documento de bug',
103
104
  decomposition: 'rascunho de decomposição',
104
105
  'decomposition-apply': 'registro das issues criadas',
106
+ 'qa-plan': 'plano de QA',
105
107
  };
106
108
 
107
109
  /**
package/src/lib/board.mjs CHANGED
@@ -218,11 +218,27 @@ export async function advanceToStage(
218
218
  const current = await getItemSingleSelectValue(token, itemId, etapaField.id).catch(() => null);
219
219
  if (!shouldAdvanceStage(current, targetStage)) return false;
220
220
  const optionId = etapaField.options?.[targetStage];
221
- if (optionId) await setItemSingleSelect(token, project.id, itemId, etapaField.id, optionId);
221
+ // Opção que não resolve é board divergente (coluna renomeada, init de outra
222
+ // versão) — pular a escrita e devolver true fazia o chamador imprimir ✅
223
+ // sem nada ter sido escrito. Melhor falhar nomeando o que faltou.
224
+ if (!optionId) {
225
+ throw new Error(
226
+ `A Etapa "${targetStage}" não existe no board (opções: ` +
227
+ `${Object.keys(etapaField.options || {}).join(', ') || 'nenhuma'}). ` +
228
+ 'O board divergiu da config — rode `spec-wave refresh` ou renomeie a coluna de volta.'
229
+ );
230
+ }
231
+ await setItemSingleSelect(token, project.id, itemId, etapaField.id, optionId);
222
232
  }
223
233
  if (statusField?.id && targetStatus) {
224
234
  const optionId = statusField.options?.[targetStatus];
225
- if (optionId) await setItemSingleSelect(token, project.id, itemId, statusField.id, optionId);
235
+ if (!optionId) {
236
+ throw new Error(
237
+ `O Status "${targetStatus}" não existe no board (opções: ` +
238
+ `${Object.keys(statusField.options || {}).join(', ') || 'nenhuma'}).`
239
+ );
240
+ }
241
+ await setItemSingleSelect(token, project.id, itemId, statusField.id, optionId);
226
242
  }
227
243
  return true;
228
244
  }
@@ -44,12 +44,16 @@ export const CRITIQUE_TOOL_NAME = 'registrar_findings';
44
44
  // Rótulo do artefato auditado, por contexto — usado no cabeçalho do comentário.
45
45
  const KIND_LABEL = {
46
46
  plan: 'plan.md', stories: 'decomposition.md', bug: 'bug.md', spec: 'spec.md',
47
+ conjunto: 'specs da milestone (conjunto)', qa: 'qa-plan.md',
47
48
  };
48
49
 
49
50
  // Prompt por tipo de auditoria. 'plan' audita o plan.md contra a spec;
50
- // 'stories' audita a decomposição proposta contra spec + plan.
51
+ // 'stories' audita a decomposição proposta contra spec + plan; 'conjunto'
52
+ // audita TODAS as specs de uma milestone entre si — é a única cujo objeto é a
53
+ // relação entre documentos, não um documento.
51
54
  const KIND_PROMPT = {
52
55
  plan: 'plan/critique', stories: 'decompose/critique', bug: 'bug/critique', spec: 'spec/critique',
56
+ conjunto: 'audit/critique', qa: 'qa/critique',
53
57
  };
54
58
 
55
59
  // A decomposição virou arquivo revisável (decomposition.md): um finding só é
@@ -62,6 +66,20 @@ const ANCHOR_RULE = `Cada finding DEVE citar, no campo "anchor", o trecho audita
62
66
  - "geral" quando o problema for da decomposição como um todo (ex.: requisito da spec que nenhuma Story cobre).
63
67
  Use EXATAMENTE os números que aparecem nos títulos "## Story N — ..." e "### Task N.M — ..." do documento.`;
64
68
 
69
+ // O qa-plan.md tem a mesma necessidade da decomposição: o finding precisa dizer
70
+ // QUAL cenário está errado, e o título "## Cenário N" é a âncora estável.
71
+ const QA_ANCHOR_RULE = `Cada finding DEVE citar, no campo "anchor", o trecho auditado:
72
+ - "Cenário N" para um problema no Cenário N (ex.: "Cenário 3");
73
+ - "geral" quando o problema for do plano como um todo (ex.: critério de aceite da spec que nenhum cenário cobre).
74
+ Use EXATAMENTE os números que aparecem nos títulos "## Cenário N — Story #X" do documento.`;
75
+
76
+ // No conjunto o achado vive ENTRE documentos: sem dizer quais, o leitor relê a
77
+ // milestone inteira procurando o par. O campo é validado como array de números
78
+ // de issue — é o equivalente da âncora "Story N" para este contexto.
79
+ const FEATURES_RULE = `Cada finding DEVE listar, no campo "features", os números das issues das Features envolvidas
80
+ (ex.: [412, 415] para uma contradição entre as duas; [412] quando o problema é de uma spec só,
81
+ visto à luz das outras). Use EXATAMENTE os números que aparecem nos títulos "## spec.md — #N ..." fornecidos.`;
82
+
65
83
  /**
66
84
  * Monta o system prompt da crítica: corpo editável + contrato de máquina.
67
85
  *
@@ -79,7 +97,9 @@ Use EXATAMENTE os números que aparecem nos títulos "## Story N — ..." e "###
79
97
  */
80
98
  function buildSystemPrompt(kind, cwd) {
81
99
  const prompt = loadPrompt(KIND_PROMPT[kind] || KIND_PROMPT.plan, ...(cwd ? [{ cwd }] : []));
82
- const anchor = kind === 'stories' ? `\n\n${ANCHOR_RULE}` : '';
100
+ const anchor = kind === 'stories' ? `\n\n${ANCHOR_RULE}`
101
+ : kind === 'qa' ? `\n\n${QA_ANCHOR_RULE}`
102
+ : kind === 'conjunto' ? `\n\n${FEATURES_RULE}` : '';
83
103
 
84
104
  const contract = `## Contrato de saída
85
105
 
@@ -134,6 +154,24 @@ function critiqueJsonSchema(kind) {
134
154
  };
135
155
  required.push('anchor');
136
156
  }
157
+ if (kind === 'qa') {
158
+ properties.anchor = {
159
+ type: 'string',
160
+ description: 'Âncora do trecho auditado: "Cenário N" ou "geral".',
161
+ };
162
+ required.push('anchor');
163
+ }
164
+ if (kind === 'conjunto') {
165
+ properties.features = {
166
+ type: 'array',
167
+ items: { type: 'integer' },
168
+ minItems: 1,
169
+ description:
170
+ 'Números das issues das Features envolvidas no finding — os "#N" dos títulos ' +
171
+ '"## spec.md — #N ..." fornecidos.',
172
+ };
173
+ required.push('features');
174
+ }
137
175
  return {
138
176
  type: 'object',
139
177
  properties: {
@@ -172,14 +210,14 @@ function describe(value) {
172
210
  return typeof value === 'object' ? 'um objeto' : `${typeof value} (${JSON.stringify(value).slice(0, 60)})`;
173
211
  }
174
212
 
175
- const ANCHOR_RE = /^(story|task)[ \t]*(\d+(?:\.\d+)?)$/i;
213
+ const ANCHOR_RE = /^(story|task|cen[aá]rio)[ \t]*(\d+(?:\.\d+)?)$/i;
176
214
 
177
215
  /**
178
216
  * Normaliza a âncora de um finding (função PURA).
179
217
  *
180
- * Aceita "Story 3", "story3", "TASK 3.2". Qualquer outra coisa inclusive
181
- * "geral" — vira ausência de âncora: melhor não citar do que citar errado e
182
- * mandar o humano para o trecho errado do documento.
218
+ * Aceita "Story 3", "story3", "TASK 3.2", "Cenário 2" (com ou sem acento).
219
+ * Qualquer outra coisa — inclusive "geral" — vira ausência de âncora: melhor
220
+ * não citar do que citar errado e mandar o humano para o trecho errado.
183
221
  *
184
222
  * @param {*} value valor cru do campo `anchor`
185
223
  * @returns {string} âncora normalizada, ou '' quando não há
@@ -187,7 +225,9 @@ const ANCHOR_RE = /^(story|task)[ \t]*(\d+(?:\.\d+)?)$/i;
187
225
  export function normalizeAnchor(value) {
188
226
  const m = ANCHOR_RE.exec(String(value ?? '').trim());
189
227
  if (!m) return '';
190
- return `${m[1].toLowerCase() === 'task' ? 'Task' : 'Story'} ${m[2]}`;
228
+ const kind = m[1].toLowerCase();
229
+ const nome = kind === 'task' ? 'Task' : kind === 'story' ? 'Story' : 'Cenário';
230
+ return `${nome} ${m[2]}`;
191
231
  }
192
232
 
193
233
  /**
@@ -245,10 +285,23 @@ export function validateCritiquePayload(payload) {
245
285
  );
246
286
  }
247
287
  const extra = itemKeys.filter(
248
- k => k !== 'severity' && k !== 'text' && k !== 'anchor' && k !== 'quote');
288
+ k => k !== 'severity' && k !== 'text' && k !== 'anchor' && k !== 'quote' && k !== 'features');
249
289
  if (extra.length > 0) {
250
290
  throw new CritiqueSchemaError(`${at} tem campo(s) não reconhecido(s): ${extra.join(', ')}.`);
251
291
  }
292
+ // `features` (kind 'conjunto'): quais issues o finding atravessa. Fora do
293
+ // formato é erro de schema — a localização é a metade do valor do achado.
294
+ let features = null;
295
+ if ('features' in item) {
296
+ if (!Array.isArray(item.features)
297
+ || item.features.length === 0
298
+ || !item.features.every(n => Number.isInteger(n) && n > 0)) {
299
+ throw new CritiqueSchemaError(
300
+ `${at}.features deveria ser um array não-vazio de números de issue, veio ${describe(item.features)}.`
301
+ );
302
+ }
303
+ features = [...new Set(item.features)];
304
+ }
252
305
  // Normaliza só caixa e espaço — isso não é ambiguidade semântica. "GRAVE"
253
306
  // passa; "gravíssimo", "critical" e "high" NÃO.
254
307
  const severity = typeof item.severity === 'string' ? item.severity.trim().toLowerCase() : null;
@@ -268,6 +321,7 @@ export function validateCritiquePayload(payload) {
268
321
  return {
269
322
  severity,
270
323
  ...(anchor ? { anchor } : {}),
324
+ ...(features ? { features } : {}),
271
325
  ...(quote ? { quote } : {}),
272
326
  text: item.text.trim(),
273
327
  };
@@ -721,6 +775,17 @@ const KIND_TRAILER = {
721
775
  'até ser removida. Um bug com causa raiz errada produz correção errada — corrija o ' +
722
776
  '`bug.md` e reaplique `spec-wave:bug`.'
723
777
  : '_Findings menores não bloqueiam a triagem._'),
778
+ qa: (graves) => (graves
779
+ ? '⛔ Há findings **graves**: a label `spec-wave:critique-failed` impede o `qa run` ' +
780
+ 'até ser removida. Corrija o `qa-plan.md` (as âncoras acima apontam para ele), ' +
781
+ 'remova a label e reaplique `spec-wave:qa` para uma nova crítica.'
782
+ : '_Findings menores não bloqueiam a execução do QA._'),
783
+ // O conjunto roda fora do ciclo de tentativas e não aplica label: o achado é
784
+ // decisão de PO entre duas specs, e o destino dele é a issue das duas pontas.
785
+ conjunto: (graves) => (graves
786
+ ? '⛔ Há findings **graves** entre specs: comente nas issues DOS DOIS lados, apontando ' +
787
+ 'uma para a outra, e resolva antes de gerar os planos — cada spec sozinha parece certa.'
788
+ : '_Nenhuma contradição entre as specs que exija decisão antes dos planos._'),
724
789
  };
725
790
 
726
791
  /**
@@ -782,8 +847,12 @@ export function renderCritiqueMarkdown({
782
847
  return parts.join('\n\n');
783
848
  }
784
849
 
850
+ // Localização do finding: a âncora ("Story N") ou, no conjunto, as Features
851
+ // que ele atravessa ("#412 × #415").
852
+ const onde = f => f.anchor
853
+ || (f.features?.length ? f.features.map(n => `#${n}`).join(' × ') : '');
785
854
  const bullets = list => list
786
- .map(f => `- ${f.anchor ? `**${f.anchor}** — ` : ''}${sanitizeFindingText(f.text)}`)
855
+ .map(f => `- ${onde(f) ? `**${onde(f)}** — ` : ''}${sanitizeFindingText(f.text)}`)
787
856
  .join('\n');
788
857
  if (graves.length > 0) parts.push(`### ❌ Graves\n\n${bullets(graves)}`);
789
858
  if (menores.length > 0) parts.push(`### ⚠️ Menores\n\n${bullets(menores)}`);
@@ -791,7 +860,7 @@ export function renderCritiqueMarkdown({
791
860
  parts.push(
792
861
  '### ↘️ Rebaixados (vieram como graves, não bloqueiam)\n\n' +
793
862
  rebaixados
794
- .map(f => `- ${f.anchor ? `**${f.anchor}** — ` : ''}${sanitizeFindingText(f.text)}\n` +
863
+ .map(f => `- ${onde(f) ? `**${onde(f)}** — ` : ''}${sanitizeFindingText(f.text)}\n` +
795
864
  ` - _Rebaixado: ${sanitizeFindingText(f.downgradeReason || 'não se sustenta como bloqueio')}._`)
796
865
  .join('\n') +
797
866
  '\n\n_Continuam valendo como observação. Se algum for mesmo grave, corrija o documento ' +
@@ -811,6 +880,7 @@ export function renderCritiqueMarkdown({
811
880
  fingerprint: findingFingerprint({ kind, anchor: f.anchor, text: f.text }),
812
881
  severity: f.severity,
813
882
  anchor: f.anchor || null,
883
+ ...(f.features?.length ? { features: f.features } : {}),
814
884
  text: sanitizeFindingText(f.text),
815
885
  ...(f.downgraded ? { downgradedFrom: f.downgraded, downgradeReason: f.downgradeReason } : {}),
816
886
  })),
@@ -832,8 +902,10 @@ export function renderCritiqueMarkdown({
832
902
  * chamador decidir entre seguir com aviso e abortar.
833
903
  *
834
904
  * @param {object} params
835
- * @param {'plan'|'stories'|'bug'} params.kind o que está sendo auditado
905
+ * @param {'plan'|'stories'|'bug'|'spec'|'conjunto'} params.kind o que está sendo auditado
836
906
  * @param {string} [params.spec] conteúdo do spec.md
907
+ * @param {Array<{number:number, title:string, content:string}>} [params.specs]
908
+ * kind 'conjunto': TODAS as specs da milestone, uma seção por Feature
837
909
  * @param {string} [params.plan] conteúdo do plan.md
838
910
  * @param {string} [params.techContextYaml] tech_context serializado em YAML
839
911
  * @param {string} [params.decomposition] conteúdo do decomposition.md
@@ -847,12 +919,17 @@ export function renderCritiqueMarkdown({
847
919
  * @returns {Promise<{grave, findings, markdown, attempt, model}>}
848
920
  */
849
921
  export async function runCritique({
850
- kind, spec, plan, techContextYaml, decomposition, bugDoc, bugReport,
922
+ kind, spec, specs, plan, techContextYaml, decomposition, qaPlan, bugDoc, bugReport,
851
923
  attempt = 1, maxAttempts = DEFAULT_MAX_CRITIQUE_ATTEMPTS,
852
924
  model, labels = [], usage, cwd, decisions = null, standalone = false,
853
925
  } = {}) {
854
926
  const sections = [];
855
927
  if (spec) sections.push(`## spec.md\n\n${spec}`);
928
+ // Conjunto: o título de cada seção carrega o "#N" que o campo `features` dos
929
+ // findings cita de volta — é o contrato de localização deste kind.
930
+ for (const s of specs || []) {
931
+ sections.push(`## spec.md — #${s.number} ${s.title}\n\n${s.content}`);
932
+ }
856
933
  if (plan) sections.push(`## plan.md\n\n${plan}`);
857
934
  if (techContextYaml) sections.push(`## tech_context\n\n\`\`\`yaml\n${techContextYaml}\n\`\`\``);
858
935
  // Cerca de QUATRO crases: o decomposition.md contém cercas de três, e uma
@@ -862,6 +939,13 @@ export async function runCritique({
862
939
  `## Decomposição proposta (decomposition.md)\n\n\`\`\`\`markdown\n${decomposition}\n\`\`\`\``
863
940
  );
864
941
  }
942
+ // Mesma cerca de quatro crases da decomposição, pelo mesmo motivo: o plano
943
+ // pode conter blocos de código de três.
944
+ if (qaPlan) {
945
+ sections.push(
946
+ `## Plano de QA auditado (qa-plan.md)\n\n\`\`\`\`markdown\n${qaPlan}\n\`\`\`\``
947
+ );
948
+ }
865
949
  // O relato é a fonte contra a qual o bug.md é auditado: a causa raiz proposta
866
950
  // tem que explicar OS SINTOMAS RELATADOS, não sintomas plausíveis quaisquer.
867
951
  if (bugReport) sections.push(`## Relato original (issue e comentários)\n\n${bugReport}`);
@@ -898,7 +982,8 @@ export async function runCritique({
898
982
  // sustenta não deve entrar no contador de tentativas nem bloquear o Action.
899
983
  const findings = downgradeUnsupportedFindings(
900
984
  report.value.findings,
901
- [spec, plan, decomposition, bugDoc, techContextYaml].filter(Boolean),
985
+ [spec, ...(specs || []).map(s => s.content), plan, decomposition, qaPlan, bugDoc, techContextYaml]
986
+ .filter(Boolean),
902
987
  );
903
988
  const grave = findings.some(f => f.severity === 'grave');
904
989
  const rebaixados = findings.filter(f => f.downgraded).length;
@@ -69,8 +69,12 @@ function invalid(reason) {
69
69
  * Regras do CommonMark que importam aqui: a cerca tem 3+ caracteres, o
70
70
  * fechamento usa o mesmo caractere e comprimento >= o da abertura, e uma cerca
71
71
  * de crase não aceita crase na info string.
72
+ *
73
+ * Exportado porque o qa-plan.md (lib/qa-plan-doc.mjs) espelha as MESMAS regras
74
+ * de parsing deste documento — duplicar o rastreador seria duas cópias para
75
+ * divergir.
72
76
  */
73
- function fenceScanner() {
77
+ export function fenceScanner() {
74
78
  let open = null;
75
79
  return {
76
80
  // true quando a linha pertence a um bloco de código (abertura, conteúdo ou
@@ -30,12 +30,12 @@ export function resolveDocDir(root, issue, type) {
30
30
  }
31
31
 
32
32
  /**
33
- * Caminhos dos três documentos de uma Feature/RFC (função PURA).
33
+ * Caminhos dos documentos de uma Feature/RFC (função PURA).
34
34
  *
35
35
  * @param {string|null} root
36
36
  * @param {{title?: string}} issue
37
37
  * @param {string|null} [type]
38
- * @returns {{slug, dirRel, dirAbs, spec, plan, decomposition}} cada documento com {rel, abs}
38
+ * @returns {{slug, dirRel, dirAbs, spec, plan, decomposition, 'qa-plan'}} cada documento com {rel, abs}
39
39
  */
40
40
  export function featureDocPaths(root, issue, type) {
41
41
  const { slug, rel, dir } = resolveDocDir(root, issue, type);
@@ -47,5 +47,8 @@ export function featureDocPaths(root, issue, type) {
47
47
  spec: doc('spec.md'),
48
48
  plan: doc('plan.md'),
49
49
  decomposition: doc('decomposition.md'),
50
+ // A chave leva o hífen do nome do documento (ARTIFACT_DOCS) de propósito:
51
+ // é o mesmo id que o resto do fluxo usa para este artefato.
52
+ 'qa-plan': doc('qa-plan.md'),
50
53
  };
51
54
  }
@@ -17,7 +17,7 @@ import {
17
17
  LABEL_SPEC, LABEL_PLAN, LABEL_CRITIQUE, LABEL_DECOMPOSE, LABEL_DECOMPOSE_APPLY,
18
18
  LABEL_DECOMPOSE_READY, LABEL_DECOMPOSED, LABEL_BUG, LABEL_BUG_APPROVED,
19
19
  LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN, LABEL_PLAN_APPROVED, LABEL_TRIAGED,
20
- LABEL_DUPLICATE, LABEL_WONT_FIX, LABEL_READY, labelNames,
20
+ LABEL_DUPLICATE, LABEL_WONT_FIX, LABEL_READY, LABEL_QA, LABEL_QA_READY, labelNames,
21
21
  } from '../config.mjs';
22
22
  import { awaitingMergeBlock } from './artifact-pr.mjs';
23
23
  import { isAwaitingMerge } from './doc-source.mjs';
@@ -71,6 +71,15 @@ export const STEPS = {
71
71
  trigger: LABEL_BUG, cli: 'generate-bug', writes: 'bug', reads: [],
72
72
  types: ['Bug'], creates: false, ai: true,
73
73
  },
74
+ // Fora do pipeline linear de propósito (a geração do plano de QA acontece com
75
+ // a Feature já implementada, não antes do decompose) — o run só chega aqui
76
+ // por `--step qa-plan`. A entrada existe pela paridade: `spec-wave:qa` é
77
+ // label de gatilho de workflow, e toda label de gatilho precisa de EXATAMENTE
78
+ // uma ação (é o teste de paridade que impede os dois modos de divergirem).
79
+ 'generate-qa-plan': {
80
+ trigger: LABEL_QA, cli: 'generate-qa-plan', writes: 'qa-plan', reads: ['spec', 'plan'],
81
+ types: ['Feature'], creates: false, ai: true,
82
+ },
74
83
  // Sem gatilho por label: são decisões humanas, listadas aqui para o `run`
75
84
  // saber dizer qual é o próximo movimento em vez de só "nada pendente".
76
85
  triage: {
@@ -137,13 +146,14 @@ const TRIGGERS = Object.values(STEPS).map(s => s.trigger).filter(Boolean);
137
146
 
138
147
  /** Documentos que cada tipo usa, na ordem em que o fluxo os produz. */
139
148
  export const DOCS_BY_TYPE = {
140
- Feature: ['spec', 'plan', 'decomposition'],
149
+ Feature: ['spec', 'plan', 'decomposition', 'qa-plan'],
141
150
  RFC: ['decomposition'],
142
151
  Bug: ['bug'],
143
152
  };
144
153
 
145
154
  const DEFAULT_DOC_PATHS = {
146
155
  spec: 'spec.md', plan: 'plan.md', decomposition: 'decomposition.md', bug: 'bug.md',
156
+ 'qa-plan': 'qa-plan.md',
147
157
  };
148
158
 
149
159
  function stepCommand(action, { issueNumber }) {
@@ -184,7 +194,7 @@ function block(code, message, unblock) {
184
194
  export function resolveForcedStep(step, { type }) {
185
195
  const alias = {
186
196
  spec: 'generate-spec', plan: 'generate-plan', bug: 'generate-bug',
187
- ready: 'validate', apply: 'decompose-apply',
197
+ ready: 'validate', apply: 'decompose-apply', 'qa-plan': 'generate-qa-plan',
188
198
  };
189
199
  const action = alias[step] || step;
190
200
  if (!STEPS[action] || STEPS[action].manual) {
@@ -279,6 +289,7 @@ export function nextStep({
279
289
  // muda o slug (e o diretório); clone velho não tem o arquivo.
280
290
  const claims = [
281
291
  [LABEL_PLAN_APPROVED, 'plan'], [LABEL_DECOMPOSED, 'decomposition'], [LABEL_BUG_APPROVED, 'bug'],
292
+ [LABEL_QA_READY, 'qa-plan'],
282
293
  ];
283
294
  for (const [label, doc] of claims) {
284
295
  if (has(label) && docsOfType.includes(doc) && docState(docs, doc) === 'missing') {
@@ -411,6 +422,7 @@ function pipelineReason(action, { pathOf }) {
411
422
  case 'decompose': return `O rascunho \`${pathOf('decomposition')}\` ainda não existe.`;
412
423
  case 'decompose-apply': return 'O rascunho foi aprovado pela crítica e espera revisão humana.';
413
424
  case 'generate-bug': return `\`${pathOf('bug')}\` ainda não existe.`;
425
+ case 'generate-qa-plan': return `Gera (ou re-critica) o plano de QA em \`${pathOf('qa-plan')}\`.`;
414
426
  default: return 'Próximo passo do fluxo.';
415
427
  }
416
428
  }
@@ -82,21 +82,26 @@ export function nextPrStep({
82
82
  return { steps: [], warnings, reason: 'PR fechado sem merge.', blocked: null };
83
83
  }
84
84
 
85
- if (merged) warnings.push('PR mergeado o board é atualizado como se o review tivesse acabado agora.');
86
- if (merged && !approved) warnings.push('Mergeado sem review aprovada: o `qa` não roda (o qa.yml também não rodaria).');
87
- if (changesRequestedAfterApproval) {
85
+ // Merge move até 🧪 QA mesmo sem aprovação formal: o autor não consegue
86
+ // aprovar o próprio PR (fluxo solo nunca teria QA por construção), e QA de
87
+ // verdade começa com o código integrado. O qa.yml tem o mesmo gatilho de
88
+ // merge — os dois modos continuam idênticos.
89
+ if (merged) warnings.push('PR já mergeado — merge move até 🧪 QA, com ou sem aprovação formal.');
90
+ if (changesRequestedAfterApproval && !merged) {
88
91
  warnings.push('Há aprovação E pedido de mudanças: o Actions moveria assim mesmo — confirme com `--yes`.');
89
92
  }
90
93
 
91
- let steps = approved ? ['code-review', 'qa'] : ['code-review'];
94
+ let steps = (approved || merged) ? ['code-review', 'qa'] : ['code-review'];
92
95
  if (only) steps = steps.filter(s => s === only);
93
96
 
94
97
  return {
95
98
  steps,
96
99
  warnings,
97
- reason: approved
98
- ? 'PR com review aprovada: board até 🧪 QA.'
99
- : 'PR sem review aprovada: board até 👀 Code Review.',
100
+ reason: merged
101
+ ? 'PR mergeado: board até 🧪 QA.'
102
+ : approved
103
+ ? 'PR com review aprovada: board até 🧪 QA.'
104
+ : 'PR sem review aprovada: board até 👀 Code Review.',
100
105
  blocked: null,
101
106
  };
102
107
  }