@spec-wave/cli 0.30.0 → 0.33.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.30.0",
3
+ "version": "0.33.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": {
@@ -11,10 +11,12 @@
11
11
  },
12
12
  "files": [
13
13
  "bin",
14
- "src"
14
+ "src",
15
+ "protocol"
15
16
  ],
16
17
  "scripts": {
17
- "test": "node --test test/*.test.mjs"
18
+ "test": "node --test test/*.test.mjs",
19
+ "skill:gen": "node scripts/generate-skill.mjs"
18
20
  },
19
21
  "engines": {
20
22
  "node": ">=20"
@@ -0,0 +1,62 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://github.com/astratech-net-br/spec-wave-cli/blob/main/packages/spec-wave/protocol/qa-result.v1.json",
4
+ "title": "spec-wave qa result v1",
5
+ "description": "Contrato do arquivo `.spec-wave/qa-result-<n>.json`, escrito pelo agente executor e lido por `spec-wave qa <n>`. Formato ADOTADO da implementação existente do comando `qa` (Tarefa Zero da rfc/spec-qa-lead.md): os campos são `cenario`/`evidencia` — não `n`/`evidence` como a §4.2 da spec propunha — estendidos com `blockedReason` (D-QAL6). `version`, `issue`, `planSha` e `headSha` são aceitos e ignorados pela CLI (a integridade do plano é conferida pela própria CLI, que re-lê o qa-plan.md após a execução).",
6
+ "type": "object",
7
+ "required": ["scenarios"],
8
+ "properties": {
9
+ "version": { "type": "integer", "const": 1 },
10
+ "issue": { "type": "integer", "minimum": 1 },
11
+ "planSha": { "type": "string", "pattern": "^[0-9a-f]{7,40}$" },
12
+ "headSha": { "type": "string", "pattern": "^[0-9a-f]{7,40}$" },
13
+ "scenarios": {
14
+ "type": "array",
15
+ "minItems": 1,
16
+ "items": {
17
+ "type": "object",
18
+ "required": ["cenario", "verdict"],
19
+ "properties": {
20
+ "cenario": {
21
+ "type": "integer",
22
+ "minimum": 1,
23
+ "description": "Número POSICIONAL do cenário no escopo da execução."
24
+ },
25
+ "verdict": {
26
+ "enum": ["pass", "fail", "blocked"]
27
+ },
28
+ "evidencia": {
29
+ "type": "string",
30
+ "description": "Evidência bruta (comando, saída, código de status). OBRIGATÓRIA e não vazia em `fail` (vira o bug.md determinístico) e em `blocked` com `blockedReason: outro`."
31
+ },
32
+ "blockedReason": {
33
+ "enum": [
34
+ "ambiente",
35
+ "setup-falhou",
36
+ "massa-de-dados",
37
+ "dependencia-nao-entregue",
38
+ "bloqueado-por-bug",
39
+ "credencial",
40
+ "outro"
41
+ ],
42
+ "description": "OBRIGATÓRIO quando `verdict` é `blocked` (D-QAL6). Proibido nos demais vereditos."
43
+ }
44
+ },
45
+ "allOf": [
46
+ {
47
+ "if": { "properties": { "verdict": { "const": "blocked" } } },
48
+ "then": { "required": ["blockedReason"] }
49
+ },
50
+ {
51
+ "if": { "properties": { "verdict": { "const": "fail" } } },
52
+ "then": { "required": ["evidencia"], "properties": { "evidencia": { "minLength": 1 } } }
53
+ },
54
+ {
55
+ "if": { "properties": { "blockedReason": { "const": "outro" } } },
56
+ "then": { "required": ["evidencia"], "properties": { "evidencia": { "minLength": 1 } } }
57
+ }
58
+ ]
59
+ }
60
+ }
61
+ }
62
+ }
@@ -0,0 +1,113 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://github.com/astratech-net-br/spec-wave-cli/blob/main/packages/spec-wave/protocol/qa-trail-report.v1.json",
4
+ "title": "spec-wave qa trail report v1",
5
+ "description": "Contrato do `docs/qa/<slug-milestone>/cycle-<n>/report.json`, escrito por `spec-wave qa-lead run` (rfc/spec-qa-lead.md §4.3). É o que a UI do SpecWave e o `qa-lead report` consomem.",
6
+ "type": "object",
7
+ "required": ["version", "milestone", "cycle", "features", "totals", "verdict"],
8
+ "properties": {
9
+ "version": { "type": "integer", "const": 1 },
10
+ "milestone": {
11
+ "type": "object",
12
+ "required": ["number", "title"],
13
+ "properties": {
14
+ "number": { "type": "integer", "minimum": 1 },
15
+ "title": { "type": "string" }
16
+ }
17
+ },
18
+ "cycle": { "type": "integer", "minimum": 1 },
19
+ "startedAt": { "type": ["string", "null"], "format": "date-time" },
20
+ "finishedAt": { "type": ["string", "null"], "format": "date-time" },
21
+ "preflight": {
22
+ "type": "object",
23
+ "required": ["ok", "checks"],
24
+ "properties": {
25
+ "ok": { "type": "boolean" },
26
+ "checks": {
27
+ "type": "array",
28
+ "items": {
29
+ "type": "object",
30
+ "required": ["name", "ok"],
31
+ "properties": {
32
+ "name": { "type": "string" },
33
+ "ok": { "type": "boolean" },
34
+ "detail": { "type": "string" }
35
+ }
36
+ }
37
+ }
38
+ }
39
+ },
40
+ "features": {
41
+ "type": "array",
42
+ "items": {
43
+ "type": "object",
44
+ "required": ["issue", "title", "verdict", "scenarios"],
45
+ "properties": {
46
+ "issue": { "type": "integer", "minimum": 1 },
47
+ "title": { "type": "string" },
48
+ "verdict": {
49
+ "enum": ["verde", "vermelho", "inconclusivo", "execucao-abortada", "fora-do-ciclo", "ja-aprovada"]
50
+ },
51
+ "scenarios": {
52
+ "type": "object",
53
+ "required": ["pass", "fail", "blocked", "total"],
54
+ "properties": {
55
+ "pass": { "type": "integer", "minimum": 0 },
56
+ "fail": { "type": "integer", "minimum": 0 },
57
+ "blocked": { "type": "integer", "minimum": 0 },
58
+ "total": { "type": "integer", "minimum": 0 }
59
+ }
60
+ },
61
+ "bugsOpened": { "type": "array", "items": { "type": "integer" } },
62
+ "durationSec": { "type": ["integer", "null"], "minimum": 0 }
63
+ }
64
+ }
65
+ },
66
+ "totals": {
67
+ "type": "object",
68
+ "required": ["pass", "fail", "blocked", "naoExecutado", "total"],
69
+ "properties": {
70
+ "pass": { "type": "integer", "minimum": 0 },
71
+ "fail": { "type": "integer", "minimum": 0 },
72
+ "blocked": { "type": "integer", "minimum": 0 },
73
+ "naoExecutado": { "type": "integer", "minimum": 0 },
74
+ "total": { "type": "integer", "minimum": 0 }
75
+ }
76
+ },
77
+ "blockedByReason": {
78
+ "type": "object",
79
+ "propertyNames": {
80
+ "enum": [
81
+ "ambiente",
82
+ "setup-falhou",
83
+ "massa-de-dados",
84
+ "dependencia-nao-entregue",
85
+ "bloqueado-por-bug",
86
+ "credencial",
87
+ "outro"
88
+ ]
89
+ },
90
+ "additionalProperties": { "type": "integer", "minimum": 0 }
91
+ },
92
+ "bugs": {
93
+ "type": "object",
94
+ "required": ["opened", "closedSincePreviousCycle", "openBlocking"],
95
+ "properties": {
96
+ "opened": { "type": "integer", "minimum": 0 },
97
+ "closedSincePreviousCycle": { "type": "integer", "minimum": 0 },
98
+ "openBlocking": { "type": "array", "items": { "type": "integer" } }
99
+ }
100
+ },
101
+ "delta": {
102
+ "type": ["object", "null"],
103
+ "properties": {
104
+ "pass": { "type": "integer" },
105
+ "blocked": { "type": "integer" }
106
+ }
107
+ },
108
+ "verdict": { "enum": ["liberada", "nao-liberada", "ciclo-abortado"] },
109
+ "terminationReason": {
110
+ "enum": ["todas-verdes", "sem-mudanca-de-estado", "teto-de-ciclos", "preflight-falhou", null]
111
+ }
112
+ }
113
+ }
@@ -282,7 +282,7 @@ export async function listProjectItems(token, projectId) {
282
282
  }
283
283
  }
284
284
  }
285
- content { ... on Issue { number title state id } }
285
+ content { ... on Issue { number title state id milestone { number title } } }
286
286
  }
287
287
  }
288
288
  }
@@ -304,6 +304,11 @@ export async function listProjectItems(token, projectId) {
304
304
  state: issue.state,
305
305
  nodeId: issue.id,
306
306
  itemId: node.id,
307
+ // Habilita filtrar o snapshot por milestone sem nenhuma chamada extra
308
+ // (`order --milestone`, e o cache do board fica mais rico de graça).
309
+ milestone: issue.milestone
310
+ ? { number: issue.milestone.number, title: issue.milestone.title }
311
+ : null,
307
312
  fields,
308
313
  });
309
314
  }
@@ -307,6 +307,27 @@ export async function listMilestones(token, owner, repo) {
307
307
  });
308
308
  }
309
309
 
310
+ // Milestone pelo número — o `qa-lead` lê a DESCRIÇÃO atual antes de atualizar
311
+ // o bloco de trilha (substituir o bloco exige conhecer o resto do texto).
312
+ export async function getMilestone(token, owner, repo, milestoneNumber) {
313
+ const octokit = makeOctokit(token);
314
+ const res = await octokit.rest.issues.getMilestone({
315
+ owner, repo, milestone_number: milestoneNumber,
316
+ });
317
+ return res.data;
318
+ }
319
+
320
+ // Atualiza SÓ a descrição do milestone. O chamador é responsável por preservar
321
+ // o que já estava lá (Release Notes etc.) — ver upsertMilestoneBlock: o
322
+ // contrato é substituir um bloco delimitado, nunca a descrição inteira.
323
+ export async function updateMilestoneDescription(token, owner, repo, milestoneNumber, description) {
324
+ const octokit = makeOctokit(token);
325
+ const res = await octokit.rest.issues.updateMilestone({
326
+ owner, repo, milestone_number: milestoneNumber, description,
327
+ });
328
+ return res.data;
329
+ }
330
+
310
331
  // Issues de uma milestone (pelo NÚMERO dela), abertas e fechadas.
311
332
  //
312
333
  // `state: 'all'` de propósito: uma Feature fechada continua contando para o
package/src/cli.mjs CHANGED
@@ -345,7 +345,59 @@ export function buildProgram() {
345
345
  const { qaRun } = await import('./commands/qa-run.mjs');
346
346
  await qaRun({ issue: issueArg, ...options })
347
347
  .catch(err => { console.error(err.message); process.exit(1); });
348
- });
348
+ })
349
+ .addHelpText('after', `
350
+ A fase de QA em duas metades (a ordem é obrigatória):
351
+ 1. plano label spec-wave:qa na FEATURE → Action gera docs/features/<slug>/qa-plan.md
352
+ (valida + critica; limpo → label spec-wave:qa-ready)
353
+ 2. executar qa <issue> — SEMPRE local, contra o checkout. Exige qa-ready na Feature:
354
+ o verde avança a Etapa sozinho, então o portão humano é a revisão do plano.
355
+
356
+ Alvos: qa <feature> (Stories ainda sem qa-approved) · qa <story> · qa <bug> (Teste de Regressão do bug.md).
357
+
358
+ Desfechos:
359
+ verde +qa-approved · Story → 📋 Homologação · Bug → 🚀 Deploy · Feature quando todas liberarem
360
+ (Bug filho aberto segura a Story mesmo verde)
361
+ vermelho 1 Bug por cenário reprovado (filho da Story, ✅ Ready, bug.md commitado) · exit 1
362
+ blocked sem fail = inconclusivo: nada move, nenhum Bug · exit 1
363
+
364
+ Executor: "qa": { "command": "..." } no .spec-wave.json (ou SPEC_WAVE_QA_CMD).
365
+ Placeholders: {contextFile} {qaPlanFile} {specFile} {issue} {type} {title}.
366
+ Re-teste após o fix: qa <story> --only <cenário> (não duplica Bug — comenta no existente).
367
+
368
+ Docs: https://astratech-net-br.github.io/spec-wave-cli/guia/qa/`);
369
+
370
+ program
371
+ .command('qa-lead')
372
+ .description('Orquestra o QA de uma trilha (milestone): prepara os planos (plan), executa o ciclo em containers paralelos (run) e reimprime relatórios (report)')
373
+ .argument('<acao>', 'plan | run | report')
374
+ .argument('[milestone]', 'Milestone da trilha, por número ou título, ex.: 12 ou "v1.4"')
375
+ .option('--watch', 'No plan: aguarda os planos disparados resolverem (qa-ready/critique-failed), com teto qa.lead.planWaitTimeoutMin')
376
+ .option('--only <features>', 'No run: sub-trilha explícita, ex.: --only 318,320 (decisão humana declarada, não relaxamento do portão)')
377
+ .option('--max-cycles <n>', 'No run: teto de ciclos desta trilha (default: qa.lead.maxCycles, 3)')
378
+ .option('--cycle <n>', 'No report: qual ciclo imprimir (default: o último)')
379
+ .option('--dry-run', 'Classifica/planeja e imprime — nenhuma label, zero container, zero escrita')
380
+ .action(async (acao, milestone, options) => {
381
+ const { qaLead } = await import('./commands/qa-lead.mjs');
382
+ await qaLead({ action: acao, milestone, ...options })
383
+ .catch(err => { console.error(err.message); process.exit(1); });
384
+ })
385
+ .addHelpText('after', `
386
+ Trilha = milestone (D-QAL1), na ordem do \`spec-wave order\` sem argumento. Duas fases (D-QAL2):
387
+ plan classifica cada Feature (pronta / portão humano / em voo / sem spec-plan) e aplica
388
+ \`spec-wave:qa\` nas que precisam de plano. PARA no portão humano: revise os qa-plan.md.
389
+ run exige a trilha inteira com \`spec-wave:qa-ready\`. Preflight global → até
390
+ qa.lead.maxParallel containers (um por Feature, backend docker) → coleta os comentários
391
+ de veredito → grava docs/qa/<slug>/cycle-<n>/{report.md,report.json} e o bloco
392
+ delimitado na descrição do milestone. Ciclo N+1 só se um Bug fechou ou um bloqueio
393
+ caiu (D-QAL7); teto de 3 ciclos.
394
+ report reimprime um ciclo já gravado, sem executar nada.
395
+
396
+ O Lead NUNCA aplica label de estado, não move card e não abre issue — isso é do \`qa\` dentro
397
+ dos containers. Configuração: bloco "qa": { "lead": { ... } } no .spec-wave.json (§4.5 da spec);
398
+ \`container.image\` é obrigatória para a fase run.
399
+
400
+ Docs: https://astratech-net-br.github.io/spec-wave-cli/guia/qa/`);
349
401
 
350
402
  program
351
403
  .command('generate-qa-plan')
@@ -354,7 +406,15 @@ export function buildProgram() {
354
406
  .action(async (options) => {
355
407
  const { generateQaPlan } = await import('./commands/generate-qa-plan.mjs');
356
408
  await generateQaPlan(options).catch(err => { console.error(err.message); process.exit(1); });
357
- });
409
+ })
410
+ .addHelpText('after', `
411
+ Só em FEATURE (o plano é um arquivo por Feature, com seções "## Cenário N — Story #X").
412
+ Arquivo ausente → gera via IA e publica em PR; presente → valida + critica COMO ESTÁ
413
+ (edições manuais preservadas — para regerar do zero, apague o arquivo e reaplique a label).
414
+ Requer spec.md e plan.md na base. Limpo → spec-wave:qa-ready (revise o plano ANTES de
415
+ rodar \`spec-wave qa <issue>\` — o veredito verde avança a Etapa sozinho).
416
+
417
+ Docs: https://astratech-net-br.github.io/spec-wave-cli/guia/qa/`);
358
418
 
359
419
  program
360
420
  .command('implement')
@@ -362,6 +422,7 @@ export function buildProgram() {
362
422
  .argument('<issue>', 'Número da issue (Feature, Story ou Task), ex.: 12 ou #12')
363
423
  .option('--feature-dir <path>', 'Caminho do docs/features/<slug> (sobrescreve a resolução automática)')
364
424
  .option('--dry-run', 'Monta o contexto e imprime o comando sem executar o spec-kit')
425
+ .option('--refresh', 'Ignora o cache local (sub-issues/dependências) e reconsulta a API')
365
426
  .action(async (issue, options) => {
366
427
  const { implement } = await import('./commands/implement.mjs');
367
428
  await implement({ issue, ...options }).catch(err => { console.error(err.message); process.exit(1); });
@@ -371,10 +432,21 @@ export function buildProgram() {
371
432
  .command('order')
372
433
  .description('Ordena as Stories pelas dependências (topológica). Sem argumento, o mapa de todas as Features com trabalho')
373
434
  .argument('[feature]', 'Número da issue da Feature, ex.: 12 ou #12. Omitido: todas as Features abertas fora de 🎉 Done')
374
- .action(async (feature) => {
435
+ .option('--milestone <ref>', 'Só as Features desta milestone (número ou título) no mapa sem argumento')
436
+ .option('--json', 'Saída em JSON estável (para agentes/scripts), sem ANSI')
437
+ .option('--remote', 'Une também o blocked_by da API (1 chamada por Story) — pega arestas criadas só pela UI')
438
+ .option('--refresh', 'Ignora o cache local e reconsulta a API')
439
+ .option('--sync', 'Grava as dependências VIVAS (body + blocked_by) de volta no decomposition.md e regenera o dependency-map.json')
440
+ .action(async (feature, options) => {
375
441
  const { order } = await import('./commands/order.mjs');
376
- await order({ feature }).catch(err => { console.error(err.message); process.exit(1); });
377
- });
442
+ await order({ feature, ...options }).catch(err => { console.error(err.message); process.exit(1); });
443
+ })
444
+ .addHelpText('after', `
445
+ As ARESTAS vêm de fontes locais (dependency-map.json do decompose --apply, decomposition.md,
446
+ linhas "Depende de:" do corpo) — zero chamada por Story. A Etapa vem de UM snapshot do board,
447
+ cacheado por ${'`cache.ttlSec`'} (default 600s; SPEC_WAVE_CACHE_TTL na env; 0 desliga) com
448
+ staleness sempre visível. blocked_by criado SÓ pela UI não está nas fontes locais: use --remote
449
+ para incluí-lo na consulta, ou --sync para gravá-lo em definitivo nos artefatos.`);
378
450
 
379
451
  program
380
452
  .command('merge')
@@ -383,6 +455,8 @@ export function buildProgram() {
383
455
  .option('--yes', 'Executa os merges (sem isso, só mostra o plano)')
384
456
  .option('--dry-run', 'Mostra o plano e sai')
385
457
  .option('--keep-branches', 'Não apaga as branches das Stories depois do merge')
458
+ .option('--remote', 'No plano: une também o blocked_by da API (a execução com --yes sempre o faz, fresco)')
459
+ .option('--refresh', 'Ignora o cache local e reconsulta a API')
386
460
  .action(async (feature, options) => {
387
461
  const { merge } = await import('./commands/merge.mjs');
388
462
  await merge({ feature, ...options }).catch(err => { console.error(err.message); process.exit(1); });
@@ -471,6 +545,7 @@ Fluxo típico (cada tema, na ordem):
471
545
  implementar implement <issue> · task start/done · story review
472
546
  entregar merge <feature> (PRs empilhados, na ordem) · run --pr <n> (board até QA)
473
547
  validar (QA) label spec-wave:qa (gera qa-plan.md) → qa <issue> [--only n] (executa e dá o veredito)
548
+ trilha inteira (milestone): qa-lead plan → [revisão humana] → qa-lead run · qa-lead report
474
549
  acompanhar info · order · move · repair-stage
475
550
 
476
551
  Docs: https://astratech-net-br.github.io/spec-wave-cli/ · spec-wave <comando> --help`);
@@ -38,6 +38,8 @@ import { detectIssueType } from '../lib/issue-type.mjs';
38
38
  import { loadConfig } from '../lib/project-root.mjs';
39
39
  import { resolveFlowContext } from '../lib/flow-run.mjs';
40
40
  import { publishArtifact } from '../lib/artifact-publish.mjs';
41
+ import { buildDependencyMap, storiesFromAppliedDoc, DEPENDENCY_MAP_FILE } from '../lib/dependency-map.mjs';
42
+ import { invalidateCache } from '../lib/net-cache.mjs';
41
43
  import { loadArtifact } from '../lib/doc-source.mjs';
42
44
  import { awaitingMergeBlock } from '../lib/artifact-pr.mjs';
43
45
  import { isAwaitingMerge } from '../lib/doc-source.mjs';
@@ -258,14 +260,14 @@ function formatItemsLintWarning(texts) {
258
260
  * dezenas de itens). Sem o `await`, a promise rejeitada não é capturada por esse
259
261
  * catch: o aviso some e vira unhandled rejection.
260
262
  */
261
- async function publishFile(ctx, { doc, pathRel, content, nextLabel = null }) {
263
+ async function publishFile(ctx, { doc, pathRel, content, nextLabel = null, extraFiles = [] }) {
262
264
  const { token, owner, repo, issue, issueNumber, base } = ctx;
263
265
  const published = await publishArtifact({
264
266
  token, owner, repo, doc,
265
267
  issueNumber: parseInt(issueNumber, 10),
266
268
  issueTitle: issue?.title || '',
267
269
  issueUrl: issue?.html_url || '',
268
- pathRel, content, base, nextLabel,
270
+ pathRel, content, base, nextLabel, extraFiles,
269
271
  });
270
272
  if (published.warning) console.warn(`⚠️ ${published.warning}`);
271
273
  return published;
@@ -626,10 +628,30 @@ async function applyDecomposition(ctx) {
626
628
  // da branch do rascunho: aquela já foi mergeada (é pré-condição do apply), e
627
629
  // reabri-la produziria um PR reintroduzindo estado antigo.
628
630
  try {
631
+ const appliedAt = new Date().toISOString();
632
+ // O dependency-map.json viaja no MESMO commit da anotação: o apply é o
633
+ // momento em que o grafo nasce pronto em memória — é ele que torna a ordem
634
+ // consultável por qualquer clone SEM pagar a API (lib/dependency-map.mjs).
635
+ const extraFiles = [];
636
+ if (doc.kind === 'stories') {
637
+ const converted = storiesFromAppliedDoc({ ...doc, appliedAt });
638
+ if (converted.ok) {
639
+ const mapRel = docRel.replace(/decomposition\.md$/, DEPENDENCY_MAP_FILE);
640
+ extraFiles.push({
641
+ path: mapRel,
642
+ content: `${JSON.stringify(buildDependencyMap({
643
+ featureNumber: number, stories: converted.stories, source: 'apply', generatedAt: appliedAt,
644
+ }), null, 2)}\n`,
645
+ });
646
+ } else {
647
+ console.warn(`⚠️ dependency-map.json não gerado: ${converted.reason}.`);
648
+ }
649
+ }
629
650
  const anotado = await publishFile(ctx, {
630
651
  doc: 'decomposition-apply',
631
652
  pathRel: docRel,
632
- content: renderDecompositionDoc({ ...doc, appliedAt: new Date().toISOString() }),
653
+ content: renderDecompositionDoc({ ...doc, appliedAt }),
654
+ extraFiles,
633
655
  });
634
656
  console.log(anotado.pr?.number
635
657
  ? `${docRel} anotado com as issues criadas — PR #${anotado.pr.number}.`
@@ -641,6 +663,10 @@ async function applyDecomposition(ctx) {
641
663
  );
642
664
  }
643
665
 
666
+ // As sub-issues da Feature mudaram — o cache local de quem consultar agora
667
+ // mentiria por até um TTL inteiro.
668
+ invalidateCache(ctx.root, `subissues-${number}`, 'board-items');
669
+
644
670
  await addLabel(token, owner, repo, number, LABEL_DECOMPOSED)
645
671
  .catch(err => console.warn(`Falha ao aplicar a label ${LABEL_DECOMPOSED}: ${err.message}`));
646
672
  // Best-effort como as duas vizinhas: as issues já existem, e derrubar o run
@@ -5,6 +5,7 @@
5
5
  import { execSync } from 'node:child_process';
6
6
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
7
7
  import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
8
9
  import * as p from '@clack/prompts';
9
10
  import chalk from 'chalk';
10
11
  import { Octokit } from '@octokit/rest';
@@ -18,7 +19,7 @@ import { getRepoVariable } from '../api/github-rest.mjs';
18
19
  import {
19
20
  CONFIG_FILE, WORKFLOW_FILES, ARTIFACT_WORKFLOW_FILES, getProvider, DEFAULT_PROVIDER,
20
21
  AI_PROVIDERS, STATUS_OPTIONS,
21
- RETIRED_STAGES, ALL_LABELS, allLabelsFor, LABEL_NEEDS_HUMAN, MODEL_LABEL_PREFIX,
22
+ RETIRED_STAGES, ALL_LABELS, allLabelsFor, LABEL_NEEDS_HUMAN, LABEL_QA_READY, MODEL_LABEL_PREFIX,
22
23
  DEFAULT_MAX_CRITIQUE_ATTEMPTS, STAGE_TRACKS, AI_ACTIONS, recommendedModelAliases,
23
24
  modelLabels, STAGE_QA,
24
25
  } from '../config.mjs';
@@ -976,11 +977,59 @@ export function featuresInQaWithoutPlan({ items = [], exists = () => false } = {
976
977
  .filter(f => !exists(f.rel));
977
978
  }
978
979
 
979
- // QA (rfc/spec-qa-skill.md): executor configurado + Features em 🧪 QA com plano.
980
+ /**
981
+ * Checks do bloco `qa.lead` (função PURA — rfc/spec-qa-lead.md §6.6).
982
+ *
983
+ * `container.image` ausente é "!" e não "✗" de propósito: sem ela a fase A
984
+ * (`qa-lead plan`) funciona inteira — só a fase B recusa, orientando.
985
+ *
986
+ * @param {object} params
987
+ * @param {object|null} [params.lead] bloco `qa.lead` do .spec-wave.json
988
+ * @param {boolean|null} [params.dockerOk] docker acessível (null = não sondado)
989
+ * @param {boolean} [params.schemasOk] protocol/*.json presentes e legíveis
990
+ * @returns {{ warn: boolean, notes: string[] }}
991
+ */
992
+ export function inspectQaLead({ lead = null, dockerOk = null, schemasOk = true } = {}) {
993
+ const notes = [];
994
+ let warn = false;
995
+ const backend = lead?.backend === 'sandbox' ? 'sandbox' : 'docker';
996
+ const image = lead?.container?.image;
997
+
998
+ if (!image) {
999
+ warn = true;
1000
+ notes.push(
1001
+ 'qa-lead: `qa.lead.container.image` ausente — a fase B (`qa-lead run`) vai recusar. ' +
1002
+ 'A fase A (`qa-lead plan`) funciona sem ela.'
1003
+ );
1004
+ } else {
1005
+ notes.push(`qa-lead: backend ${backend} · imagem ${image}.`);
1006
+ }
1007
+ if (backend === 'sandbox') {
1008
+ warn = true;
1009
+ notes.push(
1010
+ 'qa-lead: backend `sandbox` ainda não tem implementação (a API de sessão do ' +
1011
+ 'spec-wave-sandbox não existe) — use `docker`.'
1012
+ );
1013
+ } else if (dockerOk === false) {
1014
+ warn = true;
1015
+ notes.push('qa-lead: docker não acessível nesta máquina — a fase B não vai conseguir despachar containers.');
1016
+ }
1017
+ if (!schemasOk) {
1018
+ warn = true;
1019
+ notes.push(
1020
+ 'qa-lead: schemas do protocol/ (qa-result.v1.json / qa-trail-report.v1.json) ausentes ou ' +
1021
+ 'ilegíveis na instalação da CLI — reinstale o pacote.'
1022
+ );
1023
+ }
1024
+ return { warn, notes };
1025
+ }
1026
+
1027
+ // QA (rfc/spec-qa-skill.md + rfc/spec-qa-lead.md): executor configurado,
1028
+ // Features em 🧪 QA com plano, e a configuração do orquestrador de trilha.
980
1029
  // As labels novas e o workflow generate-qa-plan.yml são cobertos pelos checks
981
1030
  // existentes (higiene de labels e workflows), que leem as listas do config.mjs.
982
1031
  async function checkQa(ctx) {
983
- const name = 'QA (qa.command e planos das Features em 🧪 QA)';
1032
+ const name = 'QA (qa.command, planos das Features em 🧪 QA e qa-lead)';
984
1033
  const notes = [];
985
1034
  let status = 'ok';
986
1035
 
@@ -1022,6 +1071,32 @@ async function checkQa(ctx) {
1022
1071
  } else {
1023
1072
  notes.push(`Nenhuma Feature em ${STAGE_QA} sem plano de QA.`);
1024
1073
  }
1074
+
1075
+ // Trilha travada no portão da fase B (spec-qa-lead §6.6): Feature em
1076
+ // 🧪 QA sem `qa-ready` faz o `qa-lead run` do milestone dela recusar.
1077
+ const emQa = items.filter(i => i?.number
1078
+ && String(i.state || '').toUpperCase() !== 'CLOSED'
1079
+ && (i.fields?.['Work Item Type'] === 'Feature' || /^\s*\[FEATURE\]/i.test(i.title || ''))
1080
+ && i.fields?.Etapa === STAGE_QA);
1081
+ const semReady = [];
1082
+ for (const item of emQa) {
1083
+ const issue = await makeOctokit(ctx.token).rest.issues
1084
+ .get({ owner: ctx.cfg.owner, repo: ctx.cfg.repo, issue_number: item.number })
1085
+ .catch(() => null);
1086
+ if (!issue) continue;
1087
+ const labels = (issue.data.labels || []).map(l => (typeof l === 'string' ? l : l.name));
1088
+ if (!labels.includes(LABEL_QA_READY)) {
1089
+ semReady.push({ number: item.number, milestone: issue.data.milestone?.title || '(sem milestone)' });
1090
+ }
1091
+ }
1092
+ if (semReady.length > 0) {
1093
+ status = 'warn';
1094
+ notes.push(
1095
+ `Feature(s) em ${STAGE_QA} sem \`${LABEL_QA_READY}\`: ` +
1096
+ semReady.map(f => `#${f.number} [${f.milestone}]`).join(', ') +
1097
+ ' — o `qa-lead run` desses milestones vai recusar; rode `qa-lead plan` e revise os planos.'
1098
+ );
1099
+ }
1025
1100
  } catch (err) {
1026
1101
  notes.push(`Features em ${STAGE_QA} não verificáveis agora: ${err.message}`);
1027
1102
  }
@@ -1029,6 +1104,30 @@ async function checkQa(ctx) {
1029
1104
  notes.push('Features em 🧪 QA não verificadas (sem token, project ou raiz do repo).');
1030
1105
  }
1031
1106
 
1107
+ // Orquestrador de trilha (spec-qa-lead §6.6).
1108
+ const lead = ctx.cfg?.qa?.lead || null;
1109
+ let dockerOk = null;
1110
+ if (lead?.container?.image && lead?.backend !== 'sandbox') {
1111
+ try {
1112
+ execSync('docker info', { stdio: ['ignore', 'pipe', 'pipe'] });
1113
+ dockerOk = true;
1114
+ } catch {
1115
+ dockerOk = false;
1116
+ }
1117
+ }
1118
+ const protocolDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'protocol');
1119
+ const schemasOk = ['qa-result.v1.json', 'qa-trail-report.v1.json'].every((file) => {
1120
+ try {
1121
+ JSON.parse(readFileSync(path.join(protocolDir, file), 'utf-8'));
1122
+ return true;
1123
+ } catch {
1124
+ return false;
1125
+ }
1126
+ });
1127
+ const leadCheck = inspectQaLead({ lead, dockerOk, schemasOk });
1128
+ if (leadCheck.warn) status = status === 'fail' ? 'fail' : 'warn';
1129
+ notes.push(...leadCheck.notes);
1130
+
1032
1131
  return { name, status, detail: notes.join('\n') };
1033
1132
  }
1034
1133