@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 +5 -3
- package/protocol/qa-result.v1.json +62 -0
- package/protocol/qa-trail-report.v1.json +113 -0
- package/src/api/github-graphql.mjs +6 -1
- package/src/api/github-rest.mjs +21 -0
- package/src/cli.mjs +80 -5
- package/src/commands/decompose.mjs +29 -3
- package/src/commands/doctor.mjs +102 -3
- package/src/commands/implement.mjs +56 -44
- package/src/commands/merge.mjs +43 -14
- package/src/commands/order.mjs +350 -96
- package/src/commands/qa-lead.mjs +748 -0
- package/src/commands/qa-run.mjs +104 -25
- package/src/config.mjs +15 -0
- package/src/lib/artifact-publish.mjs +5 -2
- package/src/lib/board.mjs +14 -0
- package/src/lib/dependency-map.mjs +300 -0
- package/src/lib/doc-paths.mjs +4 -0
- package/src/lib/git-retry.mjs +82 -0
- package/src/lib/net-cache.mjs +142 -0
- package/src/lib/qa-exec.mjs +23 -2
- package/src/lib/qa-lead-backend.mjs +213 -0
- package/src/lib/qa-lead.mjs +627 -0
- package/src/lib/qa-report.mjs +65 -9
- package/src/lib/skill-compose.mjs +234 -0
- package/src/lib/story-graph.mjs +256 -0
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/skills/merge/SKILL.md +1 -0
- package/src/plugin/skills/order/SKILL.md +21 -5
- package/src/plugin/skills/qa/SKILL.md +3 -1
- package/src/plugin/skills/qa-executor/SKILL.md +76 -0
- package/src/plugin/skills/qa-lead/SKILL.md +89 -0
- package/src/templates/skill/SKILL.md +953 -298
- package/src/templates/skill/core.md +584 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spec-wave/cli",
|
|
3
|
-
"version": "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
|
}
|
package/src/api/github-rest.mjs
CHANGED
|
@@ -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
|
-
.
|
|
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
|
|
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
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|