@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.
- package/package.json +1 -1
- package/src/api/github-graphql.mjs +37 -0
- package/src/api/github-rest.mjs +21 -0
- package/src/cli.mjs +73 -6
- package/src/commands/audit.mjs +280 -0
- package/src/commands/doctor.mjs +83 -2
- package/src/commands/generate-qa-plan.mjs +421 -0
- package/src/commands/implement.mjs +12 -0
- package/src/commands/merge.mjs +292 -0
- package/src/commands/move.mjs +26 -11
- package/src/commands/order.mjs +42 -0
- package/src/commands/qa-run.mjs +813 -0
- package/src/commands/run.mjs +9 -4
- package/src/config.mjs +17 -1
- package/src/lib/artifact-pr.mjs +2 -0
- package/src/lib/board.mjs +18 -2
- package/src/lib/critique.mjs +98 -13
- package/src/lib/decomposition-doc.mjs +5 -1
- package/src/lib/doc-paths.mjs +5 -2
- package/src/lib/next-step.mjs +15 -3
- package/src/lib/pr-step.mjs +12 -7
- package/src/lib/qa-exec.mjs +314 -0
- package/src/lib/qa-plan-doc.mjs +340 -0
- package/src/lib/qa-report.mjs +340 -0
- package/src/lib/spec-audit.mjs +372 -0
- package/src/lib/tech-context.mjs +20 -14
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/skills/audit/SKILL.md +34 -0
- package/src/plugin/skills/audit/model-prompt.critique.md +34 -0
- package/src/plugin/skills/merge/SKILL.md +34 -0
- package/src/plugin/skills/order/SKILL.md +1 -0
- package/src/plugin/skills/plan/model-prompt.md +1 -0
- package/src/plugin/skills/plan/reference/tech-context.md +6 -0
- package/src/plugin/skills/preparar-feature/SKILL.md +3 -1
- package/src/plugin/skills/preparar-specs/SKILL.md +21 -1
- package/src/plugin/skills/preparar-specs/reference/revisao.md +5 -2
- package/src/plugin/skills/qa/SKILL.md +105 -0
- package/src/plugin/skills/qa/model-prompt.critique.md +44 -0
- package/src/plugin/skills/qa/model-prompt.md +68 -0
- package/src/templates/config/tech_context.yml +13 -0
- package/src/templates/skill/SKILL.md +77 -4
- package/src/templates/workflows/generate-qa-plan.yml +64 -0
- package/src/templates/workflows/qa.yml +9 -1
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
// Gera (ou re-critica) o plano de QA — docs/features/<slug>/qa-plan.md.
|
|
2
|
+
//
|
|
3
|
+
// Espelho do rascunho do decompose, com a mesma semântica de preservação:
|
|
4
|
+
// arquivo AUSENTE → gera via IA, publica em PR e critica; arquivo PRESENTE →
|
|
5
|
+
// valida + critica COMO ESTÁ, sem regerar (edições manuais são o esperado, não
|
|
6
|
+
// a exceção). Para gerar outro do zero: apagar o arquivo e reaplicar
|
|
7
|
+
// `spec-wave:qa`.
|
|
8
|
+
//
|
|
9
|
+
// A ordem interna importa (spec §6.1): a validação DETERMINÍSTICA roda ANTES da
|
|
10
|
+
// crítica — é barata e pega o que não precisa de julgamento (Story descoberta,
|
|
11
|
+
// referência a issue que não é sub-issue, campo obrigatório vazio, truncamento).
|
|
12
|
+
// Só um plano estruturalmente válido gasta uma chamada de crítica.
|
|
13
|
+
|
|
14
|
+
import { resolveToken } from '../api/auth.mjs';
|
|
15
|
+
import {
|
|
16
|
+
getIssue, removeLabel, addLabel, commentOnIssue, listIssueComments, getRepoDefaultBranch,
|
|
17
|
+
} from '../api/github-rest.mjs';
|
|
18
|
+
import { getIssueParent, listSubIssues } from '../api/github-graphql.mjs';
|
|
19
|
+
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
20
|
+
import { generateDocument } from '../lib/claude.mjs';
|
|
21
|
+
import { unwrapGeneratedDoc } from '../lib/unwrap-doc.mjs';
|
|
22
|
+
import {
|
|
23
|
+
runCritique, resolveCritiqueAttempt, renderNeedsHumanComment,
|
|
24
|
+
parseCritiqueDecisions, applyCritiqueDecisions, renderRiskAcceptedComment,
|
|
25
|
+
} from '../lib/critique.mjs';
|
|
26
|
+
import { recordUsage } from '../lib/usage-report.mjs';
|
|
27
|
+
import { resolveDocDir } from '../lib/doc-paths.mjs';
|
|
28
|
+
import { docBlobUrl } from '../lib/repo-links.mjs';
|
|
29
|
+
import { loadConfig } from '../lib/project-root.mjs';
|
|
30
|
+
import { resolveFlowContext } from '../lib/flow-run.mjs';
|
|
31
|
+
import { publishArtifact } from '../lib/artifact-publish.mjs';
|
|
32
|
+
import { loadArtifact, isAwaitingMerge } from '../lib/doc-source.mjs';
|
|
33
|
+
import { awaitingMergeBlock, renderPrLine } from '../lib/artifact-pr.mjs';
|
|
34
|
+
import { loadPrompt, systemPromptWithTools } from '../lib/prompt-loader.mjs';
|
|
35
|
+
import { buildTechContext } from '../lib/tech-context.mjs';
|
|
36
|
+
import {
|
|
37
|
+
QA_PLAN_FILE, parseQaPlanDoc, renderQaPlanDoc, validateQaPlan,
|
|
38
|
+
} from '../lib/qa-plan-doc.mjs';
|
|
39
|
+
import {
|
|
40
|
+
LABEL_QA, LABEL_QA_READY, LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN,
|
|
41
|
+
LABEL_RISK_ACCEPTED, DEFAULT_MAX_CRITIQUE_ATTEMPTS, TARGET_LANGUAGE, labelNames,
|
|
42
|
+
} from '../config.mjs';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parada por decisão do fluxo: o comentário já foi postado; o erro precisa
|
|
46
|
+
* propagar para o Action ficar vermelho — mesmo contrato do decompose.
|
|
47
|
+
*/
|
|
48
|
+
class QaPlanBlockedError extends Error {
|
|
49
|
+
constructor(message) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.name = 'QaPlanBlockedError';
|
|
52
|
+
this.blocked = true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Sobe a cadeia de pais até achar a Feature (mesma mecânica do implement).
|
|
57
|
+
async function resolveParentFeature(token, startNodeId) {
|
|
58
|
+
let current = startNodeId;
|
|
59
|
+
for (let depth = 0; depth < 5 && current; depth++) {
|
|
60
|
+
const parent = await getIssueParent(token, current);
|
|
61
|
+
if (!parent) return null;
|
|
62
|
+
if (detectIssueType({ title: parent.title }) === 'Feature') return parent;
|
|
63
|
+
current = parent.nodeId;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function generateQaPlan({ issueNumber }) {
|
|
69
|
+
const token = await resolveToken();
|
|
70
|
+
const { owner, repo, mode: runMode } = resolveFlowContext({ command: 'generate-qa-plan' });
|
|
71
|
+
console.log(`Modo de execução: ${runMode}`);
|
|
72
|
+
|
|
73
|
+
const number = parseInt(issueNumber, 10);
|
|
74
|
+
const issue = await getIssue(token, owner, repo, number);
|
|
75
|
+
const type = detectIssueType(issue);
|
|
76
|
+
const labels = labelNames(issue);
|
|
77
|
+
|
|
78
|
+
// ── Escopo por tipo (spec §3): o plano é POR FEATURE, arquivo único ────────
|
|
79
|
+
if (type !== 'Feature') {
|
|
80
|
+
console.log(`Issue #${number} é ${type || 'desconhecido'} — qa-plan é gerado só para Feature.`);
|
|
81
|
+
await removeLabel(token, owner, repo, number, LABEL_QA).catch(() => {});
|
|
82
|
+
if (type === 'Story') {
|
|
83
|
+
const feature = await resolveParentFeature(token, issue.node_id).catch(() => null);
|
|
84
|
+
await commentOnIssue(token, owner, repo, number,
|
|
85
|
+
`ℹ️ **O plano de QA é por Feature, não por Story** (D-QA1). ` +
|
|
86
|
+
(feature
|
|
87
|
+
? `Aplique \`${LABEL_QA}\` na Feature-pai **#${feature.number}** — o plano dela ` +
|
|
88
|
+
`cobre esta Story com seções \`## Cenário N — Story #${number}\`.`
|
|
89
|
+
: 'Não encontrei a Feature-pai desta Story — vincule-a como sub-issue de uma Feature e ' +
|
|
90
|
+
`aplique \`${LABEL_QA}\` lá.`) +
|
|
91
|
+
`\n\nPara **executar** os cenários desta Story: \`npx @spec-wave/cli@latest qa ${number}\`.`
|
|
92
|
+
).catch(() => {});
|
|
93
|
+
} else if (type === 'Bug') {
|
|
94
|
+
await commentOnIssue(token, owner, repo, number,
|
|
95
|
+
`ℹ️ **Bug não gera plano de QA:** o QA de um Bug usa a seção ` +
|
|
96
|
+
'`Teste de Regressão` do próprio `bug.md`. ' +
|
|
97
|
+
`Execute direto: \`npx @spec-wave/cli@latest qa ${number}\`.`
|
|
98
|
+
).catch(() => {});
|
|
99
|
+
} else {
|
|
100
|
+
await commentOnIssue(token, owner, repo, number,
|
|
101
|
+
`ℹ️ **qa-plan não se aplica a ${type || 'este tipo'}.** ` +
|
|
102
|
+
'O plano de QA é gerado para **Features** (um arquivo, com cenários por Story).'
|
|
103
|
+
).catch(() => {});
|
|
104
|
+
}
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Portões humanos (spec §6.1 passo 2): falha imediata, com remediação ────
|
|
109
|
+
for (const [label, remedio] of [
|
|
110
|
+
[LABEL_NEEDS_HUMAN, 'revise os documentos e remova a label (e a `spec-wave:critique-failed`, se houver)'],
|
|
111
|
+
[LABEL_CRITIQUE_FAILED, 'corrija o documento apontado no comentário 🔎 e remova a label'],
|
|
112
|
+
]) {
|
|
113
|
+
if (labels.includes(label)) {
|
|
114
|
+
await removeLabel(token, owner, repo, number, LABEL_QA).catch(() => {});
|
|
115
|
+
await commentOnIssue(token, owner, repo, number,
|
|
116
|
+
`⏸️ **generate-qa-plan parado:** a issue tem a label \`${label}\` — ${remedio}, ` +
|
|
117
|
+
`e reaplique \`${LABEL_QA}\`.`
|
|
118
|
+
).catch(() => {});
|
|
119
|
+
throw new QaPlanBlockedError(`portão humano pendente: ${label}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const { config, root } = loadConfig();
|
|
124
|
+
const { rel: dirRel } = resolveDocDir(root, issue, type);
|
|
125
|
+
const docRel = `${dirRel}/${QA_PLAN_FILE}`;
|
|
126
|
+
const base = await getRepoDefaultBranch(token, owner, repo).catch(() => null);
|
|
127
|
+
|
|
128
|
+
const ler = (doc, pathRel) => loadArtifact({
|
|
129
|
+
token, owner, repo, root, pathRel, doc, issueNumber: number, base,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ── spec.md e plan.md são a matéria-prima (spec §6.1 passo 3) ──────────────
|
|
133
|
+
const spec = await ler('spec', `${dirRel}/spec.md`);
|
|
134
|
+
const plan = await ler('plan', `${dirRel}/plan.md`);
|
|
135
|
+
for (const [nome, doc] of [['spec.md', spec], ['plan.md', plan]]) {
|
|
136
|
+
if (isAwaitingMerge(doc.state)) {
|
|
137
|
+
const bloqueio = awaitingMergeBlock({
|
|
138
|
+
pathRel: `${dirRel}/${nome}`, state: doc.state, pr: doc.pr, branch: doc.ref,
|
|
139
|
+
});
|
|
140
|
+
await removeLabel(token, owner, repo, number, LABEL_QA).catch(() => {});
|
|
141
|
+
await commentOnIssue(token, owner, repo, number,
|
|
142
|
+
`⏸️ **generate-qa-plan parado:** ${bloqueio.message}\n\n${bloqueio.unblock}\n\n` +
|
|
143
|
+
`Reaplique \`${LABEL_QA}\` depois do merge.`
|
|
144
|
+
).catch(() => {});
|
|
145
|
+
throw new QaPlanBlockedError(bloqueio.message);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const ausentes = [['spec.md', spec], ['plan.md', plan]]
|
|
149
|
+
.filter(([, doc]) => doc.content == null).map(([nome]) => nome);
|
|
150
|
+
if (ausentes.length > 0) {
|
|
151
|
+
console.log(`Documentos ausentes: ${ausentes.join(', ')} — nada gerado.`);
|
|
152
|
+
await removeLabel(token, owner, repo, number, LABEL_QA).catch(() => {});
|
|
153
|
+
await commentOnIssue(token, owner, repo, number,
|
|
154
|
+
`ℹ️ **Plano de QA não gerado:** falta ${ausentes.map(a => `\`${a}\``).join(' e ')} em \`${dirRel}/\`. ` +
|
|
155
|
+
'O plano deriva dos critérios de aceite da spec — gere e valide spec/plan primeiro, ' +
|
|
156
|
+
`depois reaplique \`${LABEL_QA}\`.`
|
|
157
|
+
).catch(() => {});
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Stories da Feature: o plano mapeia cenário → Story real ────────────────
|
|
162
|
+
let subIssues = [];
|
|
163
|
+
try {
|
|
164
|
+
subIssues = await listSubIssues(token, issue.node_id);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
// Sem a árvore não há como validar as referências `Story #X` — e um plano
|
|
167
|
+
// publicado sem essa checagem é o que a validação determinística existe
|
|
168
|
+
// para impedir.
|
|
169
|
+
await removeLabel(token, owner, repo, number, LABEL_QA).catch(() => {});
|
|
170
|
+
await commentOnIssue(token, owner, repo, number,
|
|
171
|
+
`❌ **generate-qa-plan falhou:** não consegui listar as sub-issues da Feature ` +
|
|
172
|
+
`(${err.message}). Reaplique \`${LABEL_QA}\` para tentar de novo.`
|
|
173
|
+
).catch(() => {});
|
|
174
|
+
throw new QaPlanBlockedError(`sub-issues não legíveis: ${err.message}`);
|
|
175
|
+
}
|
|
176
|
+
const stories = subIssues.filter(s => detectIssueType({ title: s.title, labels: s.labels }) === 'Story');
|
|
177
|
+
if (stories.length === 0) {
|
|
178
|
+
console.log('Feature sem Stories — nada a planejar.');
|
|
179
|
+
await removeLabel(token, owner, repo, number, LABEL_QA).catch(() => {});
|
|
180
|
+
await commentOnIssue(token, owner, repo, number,
|
|
181
|
+
'ℹ️ **Plano de QA não gerado:** a Feature não tem Stories (sub-issues). ' +
|
|
182
|
+
`Decomponha primeiro (\`spec-wave:decompose\`) e reaplique \`${LABEL_QA}\`.`
|
|
183
|
+
).catch(() => {});
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const usageEntries = [];
|
|
188
|
+
try {
|
|
189
|
+
await runDraftOrCritique({
|
|
190
|
+
token, owner, repo, issue, number, labels, config, root, runMode,
|
|
191
|
+
dirRel, docRel, base, spec, plan, stories, usage: usageEntries, ler,
|
|
192
|
+
});
|
|
193
|
+
} catch (err) {
|
|
194
|
+
if (!err.blocked) {
|
|
195
|
+
await removeLabel(token, owner, repo, number, LABEL_QA).catch(() => {});
|
|
196
|
+
await commentOnIssue(token, owner, repo, number,
|
|
197
|
+
'❌ **Falha ao gerar o plano de QA**\n\n' +
|
|
198
|
+
`\`\`\`\n${err.message}\n\`\`\`\n\n` +
|
|
199
|
+
`A label \`${LABEL_QA}\` foi removida para destravar o gatilho — ` +
|
|
200
|
+
'adicione-a de novo para tentar outra vez.'
|
|
201
|
+
).catch(() => {});
|
|
202
|
+
}
|
|
203
|
+
throw err;
|
|
204
|
+
} finally {
|
|
205
|
+
await recordUsage({ token, owner, repo, issueNumber: number, entries: usageEntries });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function runDraftOrCritique(ctx) {
|
|
210
|
+
const {
|
|
211
|
+
token, owner, repo, issue, number, labels, config, root, runMode,
|
|
212
|
+
dirRel, docRel, base, spec, plan, stories, usage, ler,
|
|
213
|
+
} = ctx;
|
|
214
|
+
|
|
215
|
+
// Plano existente é preservado COMO ESTÁ — inclusive o que vive num PR ainda
|
|
216
|
+
// não mergeado (é lá que o revisor edita). Mesma invariante do decompose.
|
|
217
|
+
const existente = await ler('qa-plan', docRel);
|
|
218
|
+
|
|
219
|
+
let markdown;
|
|
220
|
+
let publicado = null;
|
|
221
|
+
if (existente.content != null) {
|
|
222
|
+
const onde = existente.state === 'pending-pr'
|
|
223
|
+
? `no PR #${existente.pr?.number} (ainda não mergeado)`
|
|
224
|
+
: existente.state === 'branch-only'
|
|
225
|
+
? `na branch ${existente.ref} (sem PR aberto)`
|
|
226
|
+
: docRel;
|
|
227
|
+
console.log(`Plano encontrado ${onde} — validando e criticando o arquivo como está (sem regerar).`);
|
|
228
|
+
markdown = existente.content;
|
|
229
|
+
if (isAwaitingMerge(existente.state)) {
|
|
230
|
+
publicado = { pr: existente.pr, branch: existente.ref, unchanged: true };
|
|
231
|
+
}
|
|
232
|
+
} else {
|
|
233
|
+
console.log(`Gerando plano de QA para: ${issue.title}`);
|
|
234
|
+
const payload = {
|
|
235
|
+
feature: { number, title: issue.title },
|
|
236
|
+
stories: stories.map(s => ({ number: s.number, title: s.title, body: s.body || '' })),
|
|
237
|
+
spec: spec.content,
|
|
238
|
+
plan: plan.content,
|
|
239
|
+
};
|
|
240
|
+
const userContent =
|
|
241
|
+
'Gere o qa-plan.md a partir deste payload JSON. Os números de Story dos títulos ' +
|
|
242
|
+
'"## Cenário N — Story #X" DEVEM sair da lista `stories` abaixo — nunca invente números.\n\n' +
|
|
243
|
+
JSON.stringify(payload, null, 2);
|
|
244
|
+
|
|
245
|
+
const qaPrompt = loadPrompt('qa', { cwd: root });
|
|
246
|
+
const { content: bruto } = await generateDocument(
|
|
247
|
+
systemPromptWithTools(qaPrompt),
|
|
248
|
+
userContent,
|
|
249
|
+
{
|
|
250
|
+
action: 'qa', maxTurns: qaPrompt.maxTurns, labels, usage,
|
|
251
|
+
lint: { lang: TARGET_LANGUAGE }, withReport: true,
|
|
252
|
+
}
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
// parse + render canônico: o arquivo nasce com marcador, numeração por
|
|
256
|
+
// posição e H1 — mesmo que o modelo tenha errado qualquer um dos três.
|
|
257
|
+
let gerado;
|
|
258
|
+
try {
|
|
259
|
+
gerado = parseQaPlanDoc(unwrapGeneratedDoc(bruto), { requireMarker: false });
|
|
260
|
+
} catch (err) {
|
|
261
|
+
throw new Error(`o modelo devolveu um plano fora do formato: ${err.message}`);
|
|
262
|
+
}
|
|
263
|
+
markdown = renderQaPlanDoc({
|
|
264
|
+
title: issue.title,
|
|
265
|
+
issueNumber: number,
|
|
266
|
+
scenarios: gerado.scenarios.map(s => ({ story: s.story, body: s.body })),
|
|
267
|
+
});
|
|
268
|
+
publicado = await publishArtifact({
|
|
269
|
+
token, owner, repo, doc: 'qa-plan',
|
|
270
|
+
issueNumber: number, issueTitle: issue.title, issueUrl: issue.html_url,
|
|
271
|
+
pathRel: docRel, content: markdown, base,
|
|
272
|
+
});
|
|
273
|
+
if (publicado.warning) console.warn(`⚠️ ${publicado.warning}`);
|
|
274
|
+
console.log(`Plano publicado em ${publicado.branch} (${docRel}).`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const refDoDocumento = publicado?.branch || existente.ref || undefined;
|
|
278
|
+
const blobUrl = docBlobUrl({
|
|
279
|
+
owner, repo, pathRel: docRel, mode: runMode, root, ref: refDoDocumento,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// ── Estrutura antes de qualquer gasto: parse do arquivo como está ──────────
|
|
283
|
+
let doc;
|
|
284
|
+
try {
|
|
285
|
+
doc = parseQaPlanDoc(markdown);
|
|
286
|
+
} catch (err) {
|
|
287
|
+
await commentOnIssue(token, owner, repo, number,
|
|
288
|
+
`❌ **Não consegui ler o plano de QA.**\n\n` +
|
|
289
|
+
`\`\`\`\n${err.message}\n\`\`\`\n\n` +
|
|
290
|
+
`Corrija [\`${docRel}\`](${blobUrl}) e reaplique \`${LABEL_QA}\`. ` +
|
|
291
|
+
'Para começar de novo do zero, apague o arquivo.'
|
|
292
|
+
).catch(() => {});
|
|
293
|
+
await removeLabel(token, owner, repo, number, LABEL_QA).catch(() => {});
|
|
294
|
+
throw new QaPlanBlockedError(err.message);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── Validação determinística (spec §6.1 passo 5) — antes da crítica ────────
|
|
298
|
+
const errosDeterministicos = validateQaPlan({ doc, stories, content: markdown });
|
|
299
|
+
if (errosDeterministicos.length > 0) {
|
|
300
|
+
console.log(`Validação determinística reprovou: ${errosDeterministicos.length} problema(s) — crítica não executada.`);
|
|
301
|
+
await commentOnIssue(token, owner, repo, number,
|
|
302
|
+
'⚠️ **Plano de QA reprovado na validação determinística** (a crítica adversarial ' +
|
|
303
|
+
'nem chegou a rodar — nada de IA foi gasto):\n\n' +
|
|
304
|
+
errosDeterministicos.map(e => `- ${e}`).join('\n') +
|
|
305
|
+
`\n\nCorrija [\`${docRel}\`](${blobUrl}) e reaplique \`${LABEL_QA}\`.`
|
|
306
|
+
).catch(() => {});
|
|
307
|
+
await removeLabel(token, owner, repo, number, LABEL_QA).catch(() => {});
|
|
308
|
+
throw new QaPlanBlockedError(
|
|
309
|
+
`validação determinística reprovou o plano: ${errosDeterministicos[0]}`
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
console.log(`Plano válido: ${doc.scenarios.length} cenário(s) cobrindo ${stories.length} Story(ies).`);
|
|
313
|
+
|
|
314
|
+
// ── Crítica adversarial (kind 'qa') — mesma máquina de tentativas ──────────
|
|
315
|
+
const comments = await listIssueComments(token, owner, repo, number).catch(err => {
|
|
316
|
+
console.warn(`Não foi possível listar comentários: ${err.message} — contador de tentativas em 1.`);
|
|
317
|
+
return [];
|
|
318
|
+
});
|
|
319
|
+
const maxAttempts = Number.isInteger(config?.ai?.maxCritiqueAttempts) && config.ai.maxCritiqueAttempts > 0
|
|
320
|
+
? config.ai.maxCritiqueAttempts
|
|
321
|
+
: DEFAULT_MAX_CRITIQUE_ATTEMPTS;
|
|
322
|
+
const escalationModel = config?.ai?.escalationModel || null;
|
|
323
|
+
const { attempt, blocked } = resolveCritiqueAttempt({ comments, labels, kind: 'qa', maxAttempts });
|
|
324
|
+
const decisions = parseCritiqueDecisions(comments, 'qa');
|
|
325
|
+
|
|
326
|
+
if (blocked) {
|
|
327
|
+
console.log(`Teto de ${maxAttempts} tentativas de crítica atingido — exigindo revisão humana.`);
|
|
328
|
+
await commentOnIssue(token, owner, repo, number,
|
|
329
|
+
renderNeedsHumanComment({ kind: 'qa', attempt, maxAttempts, escalationModel })).catch(() => {});
|
|
330
|
+
await addLabel(token, owner, repo, number, LABEL_NEEDS_HUMAN);
|
|
331
|
+
await removeLabel(token, owner, repo, number, LABEL_QA);
|
|
332
|
+
throw new QaPlanBlockedError(
|
|
333
|
+
`A crítica reprovou ${maxAttempts - 1}x seguidas. Label ${LABEL_NEEDS_HUMAN} aplicada.`
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const model = attempt > 1 ? (escalationModel || undefined) : undefined;
|
|
338
|
+
if (model) console.log(`Tentativa ${attempt}: escalando a crítica para ${model}.`);
|
|
339
|
+
|
|
340
|
+
// Contexto da crítica (spec §6.1 passo 6): spec, plan, decomposition e
|
|
341
|
+
// tech_context — os documentos contra os quais o plano precisa se sustentar.
|
|
342
|
+
const decomposition = await ler('decomposition', `${dirRel}/decomposition.md`).catch(() => null);
|
|
343
|
+
const techContextYaml = buildTechContext({ issueBody: issue.body || '', cwd: root || process.cwd() }).yaml;
|
|
344
|
+
|
|
345
|
+
let critique;
|
|
346
|
+
try {
|
|
347
|
+
critique = await runCritique({
|
|
348
|
+
kind: 'qa',
|
|
349
|
+
spec: spec.content,
|
|
350
|
+
plan: plan.content,
|
|
351
|
+
decomposition: decomposition?.content || undefined,
|
|
352
|
+
techContextYaml,
|
|
353
|
+
qaPlan: markdown,
|
|
354
|
+
decisions,
|
|
355
|
+
attempt, maxAttempts, model, labels, usage, cwd: root || undefined,
|
|
356
|
+
});
|
|
357
|
+
} catch (err) {
|
|
358
|
+
// O verde do QA é automático (D-QA3), então o portão do plano não pode ser
|
|
359
|
+
// pulado: sem crítica não há `qa-ready`, e o run fica vermelho.
|
|
360
|
+
await commentOnIssue(token, owner, repo, number,
|
|
361
|
+
`❌ **A crítica adversarial do plano de QA não concluiu.**\n\n` +
|
|
362
|
+
`\`\`\`\n${err.message}\n\`\`\`\n\n` +
|
|
363
|
+
`O plano está em [\`${docRel}\`](${blobUrl}), mas **sem crítica não há liberação** — ` +
|
|
364
|
+
`o verde do QA avança o board sozinho, e este é o portão. Reaplique \`${LABEL_QA}\`.`
|
|
365
|
+
).catch(() => {});
|
|
366
|
+
await removeLabel(token, owner, repo, number, LABEL_QA).catch(() => {});
|
|
367
|
+
throw new QaPlanBlockedError(`Crítica adversarial não concluiu: ${err.message}`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
await commentOnIssue(token, owner, repo, number, critique.markdown)
|
|
371
|
+
.catch(err => console.warn(`Falha ao comentar a crítica: ${err.message}`));
|
|
372
|
+
|
|
373
|
+
const { blocking, accepted } = applyCritiqueDecisions(critique.findings, decisions, 'qa');
|
|
374
|
+
if (accepted.length > 0) {
|
|
375
|
+
await addLabel(token, owner, repo, number, LABEL_RISK_ACCEPTED).catch(() => {});
|
|
376
|
+
await commentOnIssue(token, owner, repo, number,
|
|
377
|
+
renderRiskAcceptedComment({ kind: 'qa', accepted })).catch(() => {});
|
|
378
|
+
}
|
|
379
|
+
if (blocking.length > 0) {
|
|
380
|
+
console.log(`Crítica apontou ${blocking.length} finding(s) GRAVE(s) sem decisão.`);
|
|
381
|
+
await addLabel(token, owner, repo, number, LABEL_CRITIQUE_FAILED);
|
|
382
|
+
await removeLabel(token, owner, repo, number, LABEL_QA_READY).catch(() => {});
|
|
383
|
+
await removeLabel(token, owner, repo, number, LABEL_QA);
|
|
384
|
+
throw new QaPlanBlockedError(
|
|
385
|
+
`A crítica adversarial apontou findings graves no plano (tentativa ${attempt}). ` +
|
|
386
|
+
`Corrija ${docRel} e reaplique ${LABEL_QA}.`
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
await removeLabel(token, owner, repo, number, LABEL_CRITIQUE_FAILED).catch(() => {});
|
|
390
|
+
|
|
391
|
+
// ── Liberação: qa-ready = passou na validação + crítica, espera humano ─────
|
|
392
|
+
await addLabel(token, owner, repo, number, LABEL_QA_READY);
|
|
393
|
+
await removeLabel(token, owner, repo, number, LABEL_QA);
|
|
394
|
+
|
|
395
|
+
const porStory = new Map();
|
|
396
|
+
for (const s of doc.scenarios) porStory.set(s.story, (porStory.get(s.story) || 0) + 1);
|
|
397
|
+
const resumo = [...porStory.entries()]
|
|
398
|
+
.map(([story, n]) => `- Story #${story}: ${n} cenário(s)`)
|
|
399
|
+
.join('\n');
|
|
400
|
+
|
|
401
|
+
const pr = publicado?.pr;
|
|
402
|
+
const revisao = pr?.number
|
|
403
|
+
? `${renderPrLine(publicado)}\n\n**Revise o plano no PR** (edite o que precisar — as edições ` +
|
|
404
|
+
'são preservadas) e faça o merge. '
|
|
405
|
+
: '**Revise o plano** (edite o que precisar — as edições são preservadas). ';
|
|
406
|
+
|
|
407
|
+
await commentOnIssue(token, owner, repo, number,
|
|
408
|
+
`🧪 **Plano de QA pronto para revisão humana** (${doc.scenarios.length} cenário(s)).\n\n` +
|
|
409
|
+
`📄 Arquivo: [\`${docRel}\`](${blobUrl})\n\n${resumo}\n\n` +
|
|
410
|
+
'A crítica adversarial não encontrou problemas graves e a label ' +
|
|
411
|
+
`\`${LABEL_QA_READY}\` foi aplicada — **ela é o portão humano**: o veredito verde da ` +
|
|
412
|
+
'execução avança a Etapa sozinho, então revise os cenários ANTES de rodar.\n\n' +
|
|
413
|
+
revisao +
|
|
414
|
+
'Depois execute localmente:\n' +
|
|
415
|
+
`\`\`\`\nnpx @spec-wave/cli@latest qa ${number} --dry-run\nnpx @spec-wave/cli@latest qa ${number}\n\`\`\`\n` +
|
|
416
|
+
`Para uma nova crítica depois de editar, reaplique \`${LABEL_QA}\` — o arquivo é ` +
|
|
417
|
+
'criticado como está, sem ser regerado. Para gerar outro do zero, apague o arquivo.'
|
|
418
|
+
).catch(err => console.warn(`Falha ao comentar a liberação: ${err.message}`));
|
|
419
|
+
console.log(`Plano de QA liberado para revisão (${doc.scenarios.length} cenário(s)).`);
|
|
420
|
+
return { pr: pr || null };
|
|
421
|
+
}
|
|
@@ -412,6 +412,18 @@ export function buildFeatureContext({
|
|
|
412
412
|
`Enquanto houver Story pendente, a Feature permanece em ${STAGE_DEVELOPMENT}.`
|
|
413
413
|
);
|
|
414
414
|
lines.push('');
|
|
415
|
+
// O trecho pós-implementação era o único do fluxo inteiramente manual — e o
|
|
416
|
+
// merge de PRs empilhados é ordem-dependente (apagar a branch do primeiro já
|
|
417
|
+
// fechou o segundo). O contexto termina apontando o comando que encapsula a
|
|
418
|
+
// sequência segura, em vez de deixar a mecânica por conta de quem mergeia.
|
|
419
|
+
lines.push(
|
|
420
|
+
'**Ao terminar, informe no relatório final:** os PRs ficam **empilhados** (cada um baseado no anterior) — ' +
|
|
421
|
+
'o merge é **ordem-dependente**. Depois da revisão humana (marcar cada PR como pronto), o merge é ' +
|
|
422
|
+
`\`npx @spec-wave/cli@latest merge ${feature.number}\`: ele mergeia na ordem das dependências, ` +
|
|
423
|
+
'reaponta as bases, move o board até 🧪 QA e só apaga as branches no fim. ' +
|
|
424
|
+
'**NUNCA** mergeie um PR da pilha com `--delete-branch` à mão — apagar a branch antes de reapontar o dependente fecha o PR seguinte.'
|
|
425
|
+
);
|
|
426
|
+
lines.push('');
|
|
415
427
|
lines.push(boardRuleBlockquote());
|
|
416
428
|
|
|
417
429
|
if (skipped.length > 0) {
|