@spec-wave/cli 0.13.0 → 0.15.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/README.md +39 -6
- package/bin/spec-wave.mjs +17 -1
- package/package.json +1 -1
- package/src/api/github-rest.mjs +198 -2
- package/src/commands/code-review.mjs +5 -8
- package/src/commands/decompose.mjs +412 -130
- package/src/commands/dev-agent.mjs +3 -2
- package/src/commands/doctor.mjs +239 -9
- package/src/commands/generate-plan.mjs +105 -30
- package/src/commands/generate-spec.mjs +17 -5
- package/src/commands/implement.mjs +39 -15
- package/src/commands/info.mjs +4 -3
- package/src/commands/issue.mjs +4 -4
- package/src/commands/move.mjs +162 -0
- package/src/commands/order.mjs +1 -12
- package/src/commands/qa.mjs +5 -8
- package/src/commands/refresh.mjs +4 -3
- package/src/commands/story.mjs +1 -12
- package/src/commands/task.mjs +1 -11
- package/src/commands/update.mjs +372 -71
- package/src/commands/validate.mjs +37 -22
- package/src/config.mjs +40 -1
- package/src/lib/board.mjs +88 -26
- package/src/lib/claude.mjs +315 -70
- package/src/lib/critique.mjs +391 -91
- package/src/lib/decomposition-doc.mjs +451 -0
- package/src/lib/implement-board.mjs +14 -1
- package/src/lib/pr-branch.mjs +267 -0
- package/src/lib/project-root.mjs +93 -0
- package/src/lib/templates.mjs +53 -0
- package/src/setup/files.mjs +3 -10
- package/src/templates/skill/SKILL.md +158 -30
- package/src/templates/workflows/code-review.yml +1 -1
- package/src/templates/workflows/decompose.yml +20 -6
- package/src/templates/workflows/generate-plan.yml +1 -1
- package/src/templates/workflows/generate-spec.yml +1 -1
- package/src/templates/workflows/qa.yml +1 -1
- package/src/templates/workflows/validate.yml +1 -1
package/src/lib/critique.mjs
CHANGED
|
@@ -1,34 +1,75 @@
|
|
|
1
|
-
// Crítica adversarial dos artefatos gerados por IA (plan e
|
|
1
|
+
// Crítica adversarial dos artefatos gerados por IA (plan e decomposição).
|
|
2
2
|
//
|
|
3
|
-
// Um segundo passe de IA, com papel de revisor cético, audita o
|
|
3
|
+
// Um segundo passe de IA, com papel de revisor cético, audita o artefato
|
|
4
4
|
// recém-gerado contra a spec/regras de negócio/tech_context e classifica cada
|
|
5
5
|
// contradição como GRAVE ou MENOR. Findings graves aplicam a label
|
|
6
|
-
// `spec-wave:critique-failed`, que bloqueia o
|
|
6
|
+
// `spec-wave:critique-failed`, que bloqueia o avanço até correção.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
8
|
+
// A saída é ESTRUTURADA e validada por schema. A versão anterior parseava JSON
|
|
9
|
+
// "tolerante" e, quando não conseguia, emitia a resposta bruta truncada como um
|
|
10
|
+
// finding MENOR — foi assim que um achado grave foi rebaixado e o JSON cru
|
|
11
|
+
// vazou dentro da seção "⚠️ Menores" de um comentário. Pior ainda: um payload
|
|
12
|
+
// com o campo `description` em vez de `text` era filtrado para lista vazia e
|
|
13
|
+
// renderizava "✅ nenhuma contradição", transformando um grave em passe
|
|
14
|
+
// silencioso. Agora um payload fora do contrato LANÇA.
|
|
15
|
+
//
|
|
16
|
+
// Contrato com os chamadores: erros PROPAGAM. Cada comando decide o que fazer —
|
|
17
|
+
// o generate-plan segue (o plan já foi commitado, o humano precisa dele para
|
|
18
|
+
// corrigir); o decompose aborta, porque criar work items com o portão
|
|
19
|
+
// comprovadamente não executado é exatamente o que se quer evitar.
|
|
20
|
+
|
|
21
|
+
import { generateStructured } from './claude.mjs';
|
|
22
|
+
import {
|
|
23
|
+
LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN, DEFAULT_MAX_CRITIQUE_ATTEMPTS, labelNames,
|
|
24
|
+
} from '../config.mjs';
|
|
25
|
+
|
|
26
|
+
// Severidades aceitas — enum FECHADO. O teste de prefixo /^grave/i sobre texto
|
|
27
|
+
// livre que existia aqui aceitava "gravíssimo" e rebaixava "critical"/"high"
|
|
28
|
+
// para menor sem avisar ninguém.
|
|
29
|
+
export const CRITIQUE_SEVERITIES = ['grave', 'menor'];
|
|
30
|
+
const SEVERITY_SET = new Set(CRITIQUE_SEVERITIES);
|
|
31
|
+
|
|
32
|
+
// Teto de sanidade: uma crítica com centenas de findings é sinal de modelo em
|
|
33
|
+
// loop, não de documento ruim — e o comentário estouraria o limite do GitHub.
|
|
34
|
+
const MAX_FINDINGS = 50;
|
|
35
|
+
|
|
36
|
+
// Findings são texto gerado por IA que vai para dentro de uma lista markdown, no
|
|
37
|
+
// mesmo corpo do marcador HTML de tentativa. Sem teto, um finding longo empurra
|
|
38
|
+
// o comentário para o limite de 65536 caracteres do GitHub.
|
|
39
|
+
const MAX_FINDING_CHARS = 800;
|
|
10
40
|
|
|
11
|
-
|
|
12
|
-
import { LABEL_CRITIQUE_FAILED } from '../config.mjs';
|
|
41
|
+
export const CRITIQUE_TOOL_NAME = 'registrar_findings';
|
|
13
42
|
|
|
14
|
-
//
|
|
15
|
-
const
|
|
43
|
+
// Rótulo do artefato auditado, por contexto — usado no cabeçalho do comentário.
|
|
44
|
+
const KIND_LABEL = { plan: 'plan.md', stories: 'decomposition.md' };
|
|
16
45
|
|
|
17
46
|
// Redação específica por tipo de auditoria. 'plan' audita o plan.md contra a
|
|
18
|
-
// spec; 'stories' audita
|
|
47
|
+
// spec; 'stories' audita a decomposição proposta contra spec + plan.
|
|
19
48
|
const KIND_FOCUS = {
|
|
20
49
|
plan:
|
|
21
50
|
'Audite o plan.md contra o spec.md, as regras de negócio e o tech_context fornecidos. ' +
|
|
22
51
|
'Procure decisões técnicas que contradizem ou ignoram requisitos da spec e ' +
|
|
23
52
|
'tecnologias/serviços fora do tech_context.',
|
|
24
53
|
stories:
|
|
25
|
-
'Audite
|
|
26
|
-
'Procure
|
|
27
|
-
'decisões do plan,
|
|
54
|
+
'Audite a decomposição proposta (documento Markdown com Stories e suas Tasks) contra o ' +
|
|
55
|
+
'spec.md e o plan.md fornecidos. Procure Stories que contradizem, invertem ou ignoram ' +
|
|
56
|
+
'requisitos da spec ou decisões do plan, critérios de aceite incompatíveis com as regras ' +
|
|
57
|
+
'de negócio, e Tasks que não sustentam a Story a que pertencem.',
|
|
28
58
|
};
|
|
29
59
|
|
|
60
|
+
// A decomposição virou arquivo revisável (decomposition.md): um finding só é
|
|
61
|
+
// acionável se disser ONDE está o problema. Os títulos "## Story N" e
|
|
62
|
+
// "### Task N.M" são a âncora estável — sem isso o humano relê o documento
|
|
63
|
+
// inteiro tentando adivinhar a que Story o achado se refere.
|
|
64
|
+
const ANCHOR_RULE = `Cada finding DEVE citar, no campo "anchor", o trecho auditado:
|
|
65
|
+
- "Story N" para um problema na Story N (ex.: "Story 3");
|
|
66
|
+
- "Task N.M" para um problema na Task M da Story N (ex.: "Task 3.2");
|
|
67
|
+
- "geral" quando o problema for da decomposição como um todo (ex.: requisito da spec que nenhuma Story cobre).
|
|
68
|
+
Use EXATAMENTE os números que aparecem nos títulos "## Story N — ..." e "### Task N.M — ..." do documento.`;
|
|
69
|
+
|
|
30
70
|
function buildSystemPrompt(kind) {
|
|
31
71
|
const focus = KIND_FOCUS[kind] || KIND_FOCUS.plan;
|
|
72
|
+
const anchor = kind === 'stories' ? `\n\n${ANCHOR_RULE}` : '';
|
|
32
73
|
return `Você é um revisor técnico CÉTICO e adversarial. Seu papel é encontrar problemas, não elogiar.
|
|
33
74
|
|
|
34
75
|
${focus}
|
|
@@ -39,126 +80,385 @@ Liste:
|
|
|
39
80
|
- violações de restrições explícitas (ex.: minimização de dados LGPD, limites de retenção, campos proibidos);
|
|
40
81
|
- itens que contradizem ou ignoram a spec.
|
|
41
82
|
|
|
42
|
-
Classifique cada finding:
|
|
83
|
+
Classifique cada finding no campo "severity", usando EXATAMENTE um destes dois valores:
|
|
43
84
|
- "grave": contradiz um requisito ou regra explícita — causaria implementação errada;
|
|
44
85
|
- "menor": inconsistência, omissão ou ambiguidade que merece atenção mas não inverte requisito.
|
|
45
86
|
|
|
46
87
|
NÃO invente problemas: se os documentos estiverem consistentes, retorne a lista vazia.
|
|
47
|
-
Escreva os findings em português (pt-BR)
|
|
88
|
+
Escreva os findings em português (pt-BR), em uma frase objetiva cada.${anchor}
|
|
48
89
|
|
|
49
|
-
|
|
50
|
-
|
|
90
|
+
Registre o resultado chamando a ferramenta \`${CRITIQUE_TOOL_NAME}\`. Nunca responda em texto livre.
|
|
91
|
+
Se os documentos estiverem consistentes, chame-a com "findings": [].`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function critiqueJsonSchema(kind) {
|
|
95
|
+
const properties = {
|
|
96
|
+
severity: {
|
|
97
|
+
type: 'string',
|
|
98
|
+
enum: CRITIQUE_SEVERITIES,
|
|
99
|
+
description:
|
|
100
|
+
'"grave" contradiz um requisito/regra explícita; ' +
|
|
101
|
+
'"menor" é inconsistência, omissão ou ambiguidade.',
|
|
102
|
+
},
|
|
103
|
+
text: {
|
|
104
|
+
type: 'string',
|
|
105
|
+
description: 'Descrição do finding em português (pt-BR), em uma única frase objetiva.',
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
const required = ['severity', 'text'];
|
|
109
|
+
if (kind === 'stories') {
|
|
110
|
+
properties.anchor = {
|
|
111
|
+
type: 'string',
|
|
112
|
+
description: 'Âncora do trecho auditado: "Story N", "Task N.M" ou "geral".',
|
|
113
|
+
};
|
|
114
|
+
required.push('anchor');
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
type: 'object',
|
|
118
|
+
properties: {
|
|
119
|
+
findings: {
|
|
120
|
+
type: 'array',
|
|
121
|
+
description:
|
|
122
|
+
'Contradições encontradas. Vazia quando os documentos estão consistentes.',
|
|
123
|
+
items: { type: 'object', properties, required, additionalProperties: false },
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
required: ['findings'],
|
|
127
|
+
additionalProperties: false,
|
|
128
|
+
};
|
|
51
129
|
}
|
|
52
130
|
|
|
53
131
|
/**
|
|
54
|
-
*
|
|
132
|
+
* Payload da crítica fora do schema.
|
|
55
133
|
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
|
|
134
|
+
* `transient: true` de propósito: repetir a MESMA requisição pode devolver um
|
|
135
|
+
* payload correto (ao contrário de truncamento, onde repetir dá o mesmo corte),
|
|
136
|
+
* então a validação roda dentro do retry do generateStructured. Depois das
|
|
137
|
+
* tentativas o erro sobe — nunca virando um finding de texto bruto.
|
|
138
|
+
*/
|
|
139
|
+
export class CritiqueSchemaError extends Error {
|
|
140
|
+
constructor(detail) {
|
|
141
|
+
super(`A crítica devolveu um payload fora do schema: ${detail}`);
|
|
142
|
+
this.name = 'CritiqueSchemaError';
|
|
143
|
+
this.transient = true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function describe(value) {
|
|
148
|
+
if (value === undefined) return 'undefined';
|
|
149
|
+
if (value === null) return 'null';
|
|
150
|
+
if (Array.isArray(value)) return `um array de ${value.length} item(ns)`;
|
|
151
|
+
return typeof value === 'object' ? 'um objeto' : `${typeof value} (${JSON.stringify(value).slice(0, 60)})`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const ANCHOR_RE = /^(story|task)[ \t]*(\d+(?:\.\d+)?)$/i;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Normaliza a âncora de um finding (função PURA).
|
|
61
158
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
159
|
+
* Aceita "Story 3", "story3", "TASK 3.2". Qualquer outra coisa — inclusive
|
|
160
|
+
* "geral" — vira ausência de âncora: melhor não citar do que citar errado e
|
|
161
|
+
* mandar o humano para o trecho errado do documento.
|
|
162
|
+
*
|
|
163
|
+
* @param {*} value valor cru do campo `anchor`
|
|
164
|
+
* @returns {string} âncora normalizada, ou '' quando não há
|
|
64
165
|
*/
|
|
65
|
-
export function
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
.
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
166
|
+
export function normalizeAnchor(value) {
|
|
167
|
+
const m = ANCHOR_RE.exec(String(value ?? '').trim());
|
|
168
|
+
if (!m) return '';
|
|
169
|
+
return `${m[1].toLowerCase() === 'task' ? 'Task' : 'Story'} ${m[2]}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Valida o payload da crítica contra o schema (função PURA — testável).
|
|
174
|
+
*
|
|
175
|
+
* LANÇA CritiqueSchemaError nomeando o campo ofensor. Nunca "conserta" nem
|
|
176
|
+
* rebaixa: um finding grave mal formatado vira erro, não um menor silencioso.
|
|
177
|
+
*
|
|
178
|
+
* @param {*} payload objeto devolvido pelo modelo
|
|
179
|
+
* @returns {{ grave: boolean, findings: Array<{severity:'grave'|'menor', anchor?:string, text:string}> }}
|
|
180
|
+
*/
|
|
181
|
+
export function validateCritiquePayload(payload) {
|
|
182
|
+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
183
|
+
throw new CritiqueSchemaError(
|
|
184
|
+
`o topo deveria ser um objeto {"findings": [...]}, veio ${describe(payload)}.`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
const keys = Object.keys(payload);
|
|
188
|
+
if (!('findings' in payload)) {
|
|
189
|
+
throw new CritiqueSchemaError(
|
|
190
|
+
`chave obrigatória "findings" ausente no topo ` +
|
|
191
|
+
`(chaves recebidas: ${keys.join(', ') || 'nenhuma'}).`
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
const unknown = keys.filter(k => k !== 'findings');
|
|
195
|
+
if (unknown.length > 0) {
|
|
196
|
+
throw new CritiqueSchemaError(`chave(s) não reconhecida(s) no topo: ${unknown.join(', ')}.`);
|
|
197
|
+
}
|
|
198
|
+
if (!Array.isArray(payload.findings)) {
|
|
199
|
+
throw new CritiqueSchemaError(
|
|
200
|
+
`"findings" deveria ser um array, veio ${describe(payload.findings)}.`
|
|
201
|
+
);
|
|
95
202
|
}
|
|
203
|
+
if (payload.findings.length > MAX_FINDINGS) {
|
|
204
|
+
throw new CritiqueSchemaError(
|
|
205
|
+
`"findings" trouxe ${payload.findings.length} itens (máximo ${MAX_FINDINGS}).`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const findings = payload.findings.map((item, i) => {
|
|
210
|
+
const at = `findings[${i}]`;
|
|
211
|
+
if (item === null || typeof item !== 'object' || Array.isArray(item)) {
|
|
212
|
+
throw new CritiqueSchemaError(`${at} deveria ser um objeto, veio ${describe(item)}.`);
|
|
213
|
+
}
|
|
214
|
+
const itemKeys = Object.keys(item);
|
|
215
|
+
if (!('severity' in item)) {
|
|
216
|
+
throw new CritiqueSchemaError(
|
|
217
|
+
`${at}.severity ausente (chaves recebidas: ${itemKeys.join(', ') || 'nenhuma'}).`
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
if (!('text' in item)) {
|
|
221
|
+
throw new CritiqueSchemaError(
|
|
222
|
+
`${at}.text ausente (chaves recebidas: ${itemKeys.join(', ') || 'nenhuma'}). ` +
|
|
223
|
+
'O campo com a descrição do finding DEVE se chamar "text".'
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
const extra = itemKeys.filter(k => k !== 'severity' && k !== 'text' && k !== 'anchor');
|
|
227
|
+
if (extra.length > 0) {
|
|
228
|
+
throw new CritiqueSchemaError(`${at} tem campo(s) não reconhecido(s): ${extra.join(', ')}.`);
|
|
229
|
+
}
|
|
230
|
+
// Normaliza só caixa e espaço — isso não é ambiguidade semântica. "GRAVE"
|
|
231
|
+
// passa; "gravíssimo", "critical" e "high" NÃO.
|
|
232
|
+
const severity = typeof item.severity === 'string' ? item.severity.trim().toLowerCase() : null;
|
|
233
|
+
if (!severity || !SEVERITY_SET.has(severity)) {
|
|
234
|
+
throw new CritiqueSchemaError(
|
|
235
|
+
`${at}.severity = ${JSON.stringify(item.severity)} não é um valor aceito ` +
|
|
236
|
+
`(aceitos, exatamente: ${CRITIQUE_SEVERITIES.map(s => `"${s}"`).join(' | ')}).`
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
if (typeof item.text !== 'string' || !item.text.trim()) {
|
|
240
|
+
throw new CritiqueSchemaError(
|
|
241
|
+
`${at}.text deveria ser uma string não-vazia, veio ${describe(item.text)}.`
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
const anchor = normalizeAnchor(item.anchor);
|
|
245
|
+
return { severity, ...(anchor ? { anchor } : {}), text: item.text.trim() };
|
|
246
|
+
});
|
|
96
247
|
|
|
97
|
-
|
|
98
|
-
if (!raw) return { grave: false, findings: [] };
|
|
99
|
-
const truncated = raw.length > RAW_FALLBACK_MAX ? `${raw.slice(0, RAW_FALLBACK_MAX)}…` : raw;
|
|
100
|
-
return { grave: false, findings: [{ severity: 'menor', text: truncated }] };
|
|
248
|
+
return { grave: findings.some(f => f.severity === 'grave'), findings };
|
|
101
249
|
}
|
|
102
250
|
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Marcador de tentativa e contador
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
// Marcador HTML invisível na primeira linha de cada comentário de crítica, mesma
|
|
256
|
+
// técnica do `spec-wave:usage`. É a partir dele que o contador de tentativas é
|
|
257
|
+
// derivado, sem precisar ler o timeline de eventos da issue.
|
|
258
|
+
export const CRITIQUE_MARKER_RE =
|
|
259
|
+
/<!--\s*spec-wave:critique\s+kind=(?<kind>[a-z]+)\s+attempt=(?<attempt>\d+)\s+verdict=(?<verdict>grave|limpa)\s*-->/g;
|
|
260
|
+
|
|
261
|
+
/** Monta o marcador de uma tentativa (função PURA). */
|
|
262
|
+
export function critiqueMarker({ kind, attempt, verdict }) {
|
|
263
|
+
return `<!-- spec-wave:critique kind=${kind} attempt=${attempt} verdict=${verdict} -->`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Quantas reprovas consecutivas desta crítica já existem na issue (função PURA).
|
|
268
|
+
*
|
|
269
|
+
* O contador zera em DOIS pontos:
|
|
270
|
+
* 1. crítica limpa (verdict=limpa) — os documentos foram corrigidos e o ciclo
|
|
271
|
+
* recomeça;
|
|
272
|
+
* 2. ausência de `spec-wave:critique-failed` na issue AGORA — um humano removeu
|
|
273
|
+
* a label (ou nunca houve reprova), o que é decisão explícita de recomeçar.
|
|
274
|
+
*
|
|
275
|
+
* @param {object} params
|
|
276
|
+
* @param {Array<{body?:string}>} [params.comments] comentários em ordem cronológica
|
|
277
|
+
* @param {Array<string|{name:string}>} [params.labels] labels atuais da issue
|
|
278
|
+
* @param {'plan'|'stories'} params.kind críticas de plan e de stories contam separado
|
|
279
|
+
* @param {number} [params.maxAttempts]
|
|
280
|
+
* @returns {{ attempt: number, previous: number, blocked: boolean }}
|
|
281
|
+
* attempt = a tentativa que ESTÁ para rodar; blocked = teto atingido, o
|
|
282
|
+
* chamador aplica `spec-wave:needs-human` e aborta SEM chamar a IA.
|
|
283
|
+
*/
|
|
284
|
+
export function resolveCritiqueAttempt({
|
|
285
|
+
comments = [], labels = [], kind, maxAttempts = DEFAULT_MAX_CRITIQUE_ATTEMPTS,
|
|
286
|
+
} = {}) {
|
|
287
|
+
const names = labelNames(labels);
|
|
288
|
+
if (names.includes(LABEL_NEEDS_HUMAN)) return { attempt: 0, previous: 0, blocked: true };
|
|
289
|
+
if (!names.includes(LABEL_CRITIQUE_FAILED)) return { attempt: 1, previous: 0, blocked: false };
|
|
290
|
+
|
|
291
|
+
let previous = 0;
|
|
292
|
+
for (const comment of comments) {
|
|
293
|
+
for (const m of String(comment?.body || '').matchAll(CRITIQUE_MARKER_RE)) {
|
|
294
|
+
if (m.groups.kind !== kind) continue;
|
|
295
|
+
previous = m.groups.verdict === 'limpa' ? 0 : previous + 1;
|
|
296
|
+
}
|
|
108
297
|
}
|
|
298
|
+
const attempt = previous + 1;
|
|
299
|
+
return { attempt, previous, blocked: attempt >= maxAttempts };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Comentário de esgotamento das tentativas (função PURA).
|
|
304
|
+
*
|
|
305
|
+
* Sem marcador de tentativa de propósito: este comentário não deve ser contado
|
|
306
|
+
* como uma reprova a mais.
|
|
307
|
+
*/
|
|
308
|
+
export function renderNeedsHumanComment({
|
|
309
|
+
kind = 'plan', attempt = 0, maxAttempts = DEFAULT_MAX_CRITIQUE_ATTEMPTS, escalationModel = null,
|
|
310
|
+
} = {}) {
|
|
311
|
+
return [
|
|
312
|
+
`🛑 **Crítica adversarial esgotou as tentativas** ` +
|
|
313
|
+
`(${attempt || maxAttempts}/${maxAttempts} — ${KIND_LABEL[kind] || KIND_LABEL.plan}).`,
|
|
314
|
+
'As reprovas anteriores estão nos comentários acima' +
|
|
315
|
+
(escalationModel ? ` (a última rodou no modelo de escalação \`${escalationModel}\`).` : '.'),
|
|
316
|
+
`A label \`${LABEL_NEEDS_HUMAN}\` foi aplicada e o fluxo está **parado**: nenhuma nova ` +
|
|
317
|
+
'crítica nem decomposição roda enquanto ela existir.',
|
|
318
|
+
`Para retomar: corrija os documentos, remova \`${LABEL_NEEDS_HUMAN}\` e ` +
|
|
319
|
+
`\`${LABEL_CRITIQUE_FAILED}\`, e reaplique a label de gatilho.`,
|
|
320
|
+
].join('\n\n');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
// Renderização do comentário
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
109
326
|
|
|
327
|
+
// A remediação é DIFERENTE por contexto e a versão anterior falava sempre em
|
|
328
|
+
// `spec-wave:ready` — instrução errada no decompose, onde a Feature já passou por
|
|
329
|
+
// ele e o que precisa de correção é o rascunho da decomposição.
|
|
330
|
+
const KIND_TRAILER = {
|
|
331
|
+
plan: (graves) => (graves
|
|
332
|
+
? `⛔ Há findings **graves**: a label \`${LABEL_CRITIQUE_FAILED}\` bloqueia o ` +
|
|
333
|
+
'`spec-wave:ready` até ser removida. Corrija o `plan.md` (ou a `spec.md`) e ' +
|
|
334
|
+
'reaplique `spec-wave:plan`.'
|
|
335
|
+
: '_Findings menores não bloqueiam o `spec-wave:ready`._'),
|
|
336
|
+
stories: (graves) => (graves
|
|
337
|
+
? '⛔ Há findings **graves**: **nenhuma Story/Task foi criada**. Corrija o ' +
|
|
338
|
+
`\`decomposition.md\` (as âncoras acima apontam para ele), remova a label ` +
|
|
339
|
+
`\`${LABEL_CRITIQUE_FAILED}\` e reaplique \`spec-wave:decompose\` para uma nova crítica.`
|
|
340
|
+
: '_Findings menores não bloqueiam a decomposição._'),
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Sanitiza o texto de um finding (função PURA).
|
|
345
|
+
*
|
|
346
|
+
* Um finding é texto gerado por IA que entra numa lista markdown compartilhando o
|
|
347
|
+
* corpo do comentário com o marcador HTML de tentativa. Sem sanitizar: uma quebra
|
|
348
|
+
* de linha parte o item da lista, uma cerca ``` engole o resto do comentário, e um
|
|
349
|
+
* `-->` fecha o marcador antes da hora — corrompendo o contador de tentativas.
|
|
350
|
+
*/
|
|
351
|
+
export function sanitizeFindingText(text) {
|
|
352
|
+
const clean = String(text ?? '')
|
|
353
|
+
// ‑ (non-breaking hyphen) e (zero-width space) mantêm a leitura
|
|
354
|
+
// e desarmam a sintaxe.
|
|
355
|
+
.replace(/<!--/g, '<!‑-')
|
|
356
|
+
.replace(/-->/g, '--‑>')
|
|
357
|
+
.replace(/```/g, '```')
|
|
358
|
+
.replace(/\s+/g, ' ')
|
|
359
|
+
.replace(/^[-*+>#\s]+/, '')
|
|
360
|
+
.trim();
|
|
361
|
+
return clean.length > MAX_FINDING_CHARS ? `${clean.slice(0, MAX_FINDING_CHARS)}…` : clean;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Monta o comentário da crítica (função PURA — testável).
|
|
366
|
+
*
|
|
367
|
+
* Primeira linha = marcador de tentativa. O texto de fecho depende do CONTEXTO
|
|
368
|
+
* (`kind`), porque a remediação é diferente: no plan é destravar o
|
|
369
|
+
* `spec-wave:ready`; no decompose é que nenhuma Story foi criada.
|
|
370
|
+
*/
|
|
371
|
+
export function renderCritiqueMarkdown({
|
|
372
|
+
kind = 'plan', findings = [], attempt = 1,
|
|
373
|
+
maxAttempts = DEFAULT_MAX_CRITIQUE_ATTEMPTS, model = '',
|
|
374
|
+
} = {}) {
|
|
110
375
|
const graves = findings.filter(f => f.severity === 'grave');
|
|
111
376
|
const menores = findings.filter(f => f.severity === 'menor');
|
|
112
|
-
const parts = [
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
377
|
+
const parts = [
|
|
378
|
+
critiqueMarker({ kind, attempt, verdict: graves.length > 0 ? 'grave' : 'limpa' }),
|
|
379
|
+
`🔎 **Crítica adversarial (spec-wave)** — ${KIND_LABEL[kind] || KIND_LABEL.plan} · ` +
|
|
380
|
+
`tentativa ${attempt}/${maxAttempts}${model ? ` · modelo \`${model}\`` : ''}`,
|
|
381
|
+
];
|
|
382
|
+
|
|
383
|
+
if (findings.length === 0) {
|
|
384
|
+
parts.push('✅ Nenhuma contradição encontrada.');
|
|
385
|
+
return parts.join('\n\n');
|
|
118
386
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
387
|
+
|
|
388
|
+
const bullets = list => list
|
|
389
|
+
.map(f => `- ${f.anchor ? `**${f.anchor}** — ` : ''}${sanitizeFindingText(f.text)}`)
|
|
390
|
+
.join('\n');
|
|
391
|
+
if (graves.length > 0) parts.push(`### ❌ Graves\n\n${bullets(graves)}`);
|
|
392
|
+
if (menores.length > 0) parts.push(`### ⚠️ Menores\n\n${bullets(menores)}`);
|
|
393
|
+
parts.push((KIND_TRAILER[kind] || KIND_TRAILER.plan)(graves.length > 0));
|
|
124
394
|
return parts.join('\n\n');
|
|
125
395
|
}
|
|
126
396
|
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
// Execução
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
|
|
127
401
|
/**
|
|
128
402
|
* Roda a crítica adversarial sobre os artefatos fornecidos.
|
|
129
403
|
*
|
|
130
|
-
* Seções ausentes (spec/plan/tech_context/
|
|
131
|
-
*
|
|
132
|
-
*
|
|
404
|
+
* Seções ausentes (spec/plan/tech_context/decomposition) são omitidas do prompt.
|
|
405
|
+
* Erros PROPAGAM — inclusive CritiqueSchemaError depois dos retries. Cabe ao
|
|
406
|
+
* chamador decidir entre seguir com aviso e abortar.
|
|
133
407
|
*
|
|
134
408
|
* @param {object} params
|
|
135
409
|
* @param {'plan'|'stories'} params.kind o que está sendo auditado
|
|
136
410
|
* @param {string} [params.spec] conteúdo do spec.md
|
|
137
411
|
* @param {string} [params.plan] conteúdo do plan.md
|
|
138
412
|
* @param {string} [params.techContextYaml] tech_context serializado em YAML
|
|
139
|
-
* @param {
|
|
140
|
-
* @param {
|
|
141
|
-
* @
|
|
413
|
+
* @param {string} [params.decomposition] conteúdo do decomposition.md
|
|
414
|
+
* @param {number} [params.attempt] tentativa em curso (cabeçalho e marcador)
|
|
415
|
+
* @param {number} [params.maxAttempts]
|
|
416
|
+
* @param {string} [params.model] modelo imposto (escalada); undefined = cadeia normal
|
|
417
|
+
* @param {Array} [params.labels] labels da issue (override por label)
|
|
418
|
+
* @param {object[]} [params.usage] coletor de uso de IA
|
|
419
|
+
* @returns {Promise<{grave, findings, markdown, attempt, model}>}
|
|
142
420
|
*/
|
|
143
|
-
export async function runCritique({
|
|
421
|
+
export async function runCritique({
|
|
422
|
+
kind, spec, plan, techContextYaml, decomposition,
|
|
423
|
+
attempt = 1, maxAttempts = DEFAULT_MAX_CRITIQUE_ATTEMPTS,
|
|
424
|
+
model, labels = [], usage,
|
|
425
|
+
} = {}) {
|
|
144
426
|
const sections = [];
|
|
145
427
|
if (spec) sections.push(`## spec.md\n\n${spec}`);
|
|
146
428
|
if (plan) sections.push(`## plan.md\n\n${plan}`);
|
|
147
429
|
if (techContextYaml) sections.push(`## tech_context\n\n\`\`\`yaml\n${techContextYaml}\n\`\`\``);
|
|
148
|
-
|
|
430
|
+
// Cerca de QUATRO crases: o decomposition.md contém cercas de três, e uma
|
|
431
|
+
// cerca de três aqui terminaria no primeiro bloco de código do documento.
|
|
432
|
+
if (decomposition) {
|
|
433
|
+
sections.push(
|
|
434
|
+
`## Decomposição proposta (decomposition.md)\n\n\`\`\`\`markdown\n${decomposition}\n\`\`\`\``
|
|
435
|
+
);
|
|
436
|
+
}
|
|
149
437
|
const userContent = sections.join('\n\n') || '(nenhum documento fornecido)';
|
|
150
438
|
|
|
151
|
-
const
|
|
439
|
+
const report = await generateStructured(buildSystemPrompt(kind), userContent, {
|
|
152
440
|
action: 'critique',
|
|
153
441
|
temperature: 0,
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
442
|
+
schema: {
|
|
443
|
+
name: CRITIQUE_TOOL_NAME,
|
|
444
|
+
description:
|
|
445
|
+
'Registra o resultado da crítica adversarial. Use SEMPRE esta ferramenta — ' +
|
|
446
|
+
'nunca responda em texto livre. Lista vazia significa "nenhuma contradição".',
|
|
447
|
+
jsonSchema: critiqueJsonSchema(kind),
|
|
448
|
+
validate: validateCritiquePayload,
|
|
449
|
+
},
|
|
450
|
+
model,
|
|
451
|
+
labels,
|
|
159
452
|
usage,
|
|
453
|
+
withReport: true,
|
|
160
454
|
});
|
|
161
455
|
|
|
162
|
-
const { grave, findings } =
|
|
163
|
-
return {
|
|
456
|
+
const { grave, findings } = report.value;
|
|
457
|
+
return {
|
|
458
|
+
grave,
|
|
459
|
+
findings,
|
|
460
|
+
attempt,
|
|
461
|
+
model: report.model,
|
|
462
|
+
markdown: renderCritiqueMarkdown({ kind, findings, attempt, maxAttempts, model: report.model }),
|
|
463
|
+
};
|
|
164
464
|
}
|