@spec-wave/cli 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +39 -27
  2. package/bin/spec-wave.mjs +14 -4
  3. package/package.json +1 -1
  4. package/src/api/github-graphql.mjs +0 -4
  5. package/src/api/github-rest.mjs +0 -13
  6. package/src/commands/code-review.mjs +5 -8
  7. package/src/commands/decompose.mjs +410 -251
  8. package/src/commands/dev-agent.mjs +3 -2
  9. package/src/commands/doctor.mjs +239 -9
  10. package/src/commands/generate-plan.mjs +111 -51
  11. package/src/commands/generate-spec.mjs +20 -22
  12. package/src/commands/implement.mjs +46 -24
  13. package/src/commands/info.mjs +4 -3
  14. package/src/commands/issue.mjs +4 -4
  15. package/src/commands/move.mjs +162 -0
  16. package/src/commands/order.mjs +1 -12
  17. package/src/commands/qa.mjs +5 -8
  18. package/src/commands/refresh.mjs +4 -3
  19. package/src/commands/story.mjs +1 -12
  20. package/src/commands/task.mjs +1 -11
  21. package/src/commands/update.mjs +43 -19
  22. package/src/commands/validate.mjs +47 -35
  23. package/src/config.mjs +40 -6
  24. package/src/lib/board.mjs +88 -26
  25. package/src/lib/claude.mjs +315 -70
  26. package/src/lib/critique.mjs +391 -91
  27. package/src/lib/decomposition-doc.mjs +451 -0
  28. package/src/lib/implement-board.mjs +14 -1
  29. package/src/lib/project-root.mjs +93 -0
  30. package/src/lib/templates.mjs +53 -0
  31. package/src/setup/files.mjs +3 -10
  32. package/src/templates/skill/SKILL.md +137 -61
  33. package/src/templates/workflows/code-review.yml +1 -1
  34. package/src/templates/workflows/decompose.yml +20 -6
  35. package/src/templates/workflows/generate-plan.yml +1 -1
  36. package/src/templates/workflows/generate-spec.yml +1 -1
  37. package/src/templates/workflows/qa.yml +1 -1
  38. package/src/templates/workflows/validate.yml +1 -1
  39. package/src/lib/feature-docs.mjs +0 -89
  40. package/src/lib/force.mjs +0 -34
@@ -1,34 +1,75 @@
1
- // Crítica adversarial dos artefatos gerados por IA (plan e stories).
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 documento
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 `spec-wave:ready` até correção.
6
+ // `spec-wave:critique-failed`, que bloqueia o avanço até correção.
7
7
  //
8
- // Contrato: a crítica NUNCA deve derrubar o fluxo principal parse tolerante
9
- // a JSON sujo, e falhas de API são tratadas pelo chamador como não-fatais.
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
- import { generateDocument } from './claude.mjs';
12
- import { LABEL_CRITIQUE_FAILED } from '../config.mjs';
41
+ export const CRITIQUE_TOOL_NAME = 'registrar_findings';
13
42
 
14
- // Tamanho máximo da resposta bruta preservada no fallback de parse.
15
- const RAW_FALLBACK_MAX = 400;
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 as stories propostas contra spec + plan.
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 as Stories propostas (JSON) contra o spec.md e o plan.md fornecidos. ' +
26
- 'Procure stories que contradizem, invertem ou ignoram requisitos da spec ou ' +
27
- 'decisões do plan, e critérios de aceite incompatíveis com as regras de negócio.',
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
- Responda APENAS com JSON neste formato, sem texto adicional:
50
- {"findings": [{"severity": "grave"|"menor", "text": "..."}]}`;
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
- * Interpreta a resposta do modelo crítico (função PURA — testável).
132
+ * Payload da crítica fora do schema.
55
133
  *
56
- * Tolerante a JSON sujo: fences de código, texto ao redor do objeto e
57
- * severities em maiúsculas/variantes ("GRAVE", "Grave"). Qualquer severity que
58
- * não comece com "grave" vira "menor". Se nada parseável for encontrado,
59
- * retorna a resposta bruta (truncada) como um único finding menor a crítica
60
- * nunca deve explodir.
134
+ * `transient: true` de propósito: repetir a MESMA requisição pode devolver um
135
+ * payload correto (ao contrário de truncamento, onde repetir 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
- * @param {string} text resposta bruta do modelo
63
- * @returns {{ grave: boolean, findings: Array<{ severity: 'grave'|'menor', text: string }> }}
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 parseCritiqueResponse(text) {
66
- const raw = (text || '').trim();
67
-
68
- // Candidatos a JSON, do mais provável ao mais permissivo: conteúdo de fence
69
- // de código, resposta inteira, primeiro objeto {...} encontrado no texto.
70
- const candidates = [];
71
- const fence = raw.match(/```[a-zA-Z]*\s*\n?([\s\S]*?)```/);
72
- if (fence) candidates.push(fence[1]);
73
- candidates.push(raw);
74
- const obj = raw.match(/\{[\s\S]*\}/);
75
- if (obj) candidates.push(obj[0]);
76
-
77
- for (const candidate of candidates) {
78
- let parsed;
79
- try {
80
- parsed = JSON.parse(candidate.trim());
81
- } catch {
82
- continue; // tenta o próximo candidato
83
- }
84
- const list = Array.isArray(parsed?.findings) ? parsed.findings
85
- : Array.isArray(parsed) ? parsed
86
- : null;
87
- if (!list) continue;
88
- const findings = list
89
- .filter(f => f && typeof f.text === 'string' && f.text.trim())
90
- .map(f => ({
91
- severity: /^grave/i.test(String(f.severity || '').trim()) ? 'grave' : 'menor',
92
- text: f.text.trim(),
93
- }));
94
- return { grave: findings.some(f => f.severity === 'grave'), findings };
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
- // Fallback: resposta não-parseável finding menor com a resposta bruta.
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
- // Monta o comentário de issue a partir dos findings classificados.
104
- function renderMarkdown(findings) {
105
- const header = '🔎 **Crítica adversarial (spec-wave)**';
106
- if (findings.length === 0) {
107
- return `${header}\n\n✅ crítica não encontrou contradições.`;
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 = [header];
113
- if (graves.length > 0) {
114
- parts.push(`### Graves\n\n${graves.map(f => `- ${f.text}`).join('\n')}`);
115
- }
116
- if (menores.length > 0) {
117
- parts.push(`### ⚠️ Menores\n\n${menores.map(f => `- ${f.text}`).join('\n')}`);
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
- parts.push(graves.length > 0
120
- ? `⛔ findings **graves**: a label \`${LABEL_CRITIQUE_FAILED}\` bloqueia o ` +
121
- `\`spec-wave:ready\` até ser removida após a correção dos pontos acima.`
122
- : `_Findings menores não bloqueiam o fluxo. Se houvesse graves, a label ` +
123
- `\`${LABEL_CRITIQUE_FAILED}\` bloquearia o \`spec-wave:ready\` até ser removida após correção._`);
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/stories) são simplesmente omitidas
131
- * do prompt. Erros de API PROPAGAM — o chamador deve tratar com try/catch e
132
- * seguir o fluxo principal (crítica indisponível não é fatal).
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 {object[]} [params.stories] stories propostas (antes da criação)
140
- * @param {object[]} [params.usage] coletor de uso de IA (repassado ao generateDocument)
141
- * @returns {Promise<{ grave: boolean, findings: Array<{ severity: string, text: string }>, markdown: string }>}
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({ kind, spec, plan, techContextYaml, stories, usage } = {}) {
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
- if (stories) sections.push(`## Stories propostas (JSON)\n\n\`\`\`json\n${JSON.stringify(stories, null, 2)}\n\`\`\``);
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 raw = await generateDocument(buildSystemPrompt(kind), userContent, {
439
+ const report = await generateStructured(buildSystemPrompt(kind), userContent, {
152
440
  action: 'critique',
153
441
  temperature: 0,
154
- // Teto próprio: a crítica devolve uma lista JSON de findings, bem menor que
155
- // um spec/plan. 4096 ficava justo quando os dois documentos são longos e o
156
- // JSON vinha cortado — o parse falhava sem dizer por quê. Com a detecção de
157
- // truncamento o corte agora é explícito, e a folga evita chegar nele.
158
- maxTokens: 8192,
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 } = parseCritiqueResponse(raw);
163
- return { grave, findings, markdown: renderMarkdown(findings) };
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
  }