@spec-wave/cli 0.15.0 → 0.16.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 (75) hide show
  1. package/README.md +1 -0
  2. package/bin/spec-wave.mjs +41 -2
  3. package/package.json +8 -2
  4. package/src/agent/anthropic-agent.mjs +337 -0
  5. package/src/agent/errors.mjs +33 -0
  6. package/src/agent/index.mjs +108 -0
  7. package/src/agent/openrouter-agent.mjs +378 -0
  8. package/src/agent/run-types.mjs +59 -0
  9. package/src/agent/telemetry.mjs +54 -0
  10. package/src/agent/tools.mjs +452 -0
  11. package/src/agent/tracing.mjs +106 -0
  12. package/src/api/github-rest.mjs +8 -0
  13. package/src/commands/bug.mjs +8 -0
  14. package/src/commands/code-review.mjs +45 -4
  15. package/src/commands/decompose.mjs +11 -49
  16. package/src/commands/dev-agent.mjs +3 -3
  17. package/src/commands/doctor.mjs +77 -6
  18. package/src/commands/generate-bug.mjs +195 -0
  19. package/src/commands/generate-plan.mjs +6 -20
  20. package/src/commands/generate-spec.mjs +6 -22
  21. package/src/commands/implement.mjs +105 -2
  22. package/src/commands/init.mjs +3 -3
  23. package/src/commands/install-skill.mjs +72 -16
  24. package/src/commands/issue.mjs +9 -7
  25. package/src/commands/move.mjs +11 -1
  26. package/src/commands/qa.mjs +23 -2
  27. package/src/commands/refresh.mjs +145 -5
  28. package/src/commands/triage.mjs +174 -0
  29. package/src/commands/update.mjs +16 -3
  30. package/src/commands/validate.mjs +82 -10
  31. package/src/config.mjs +159 -1
  32. package/src/lib/bug-context.mjs +160 -0
  33. package/src/lib/bug-doc.mjs +51 -0
  34. package/src/lib/bug-triage.mjs +81 -0
  35. package/src/lib/claude.mjs +71 -254
  36. package/src/lib/critique.mjs +43 -30
  37. package/src/lib/implement-board.mjs +12 -1
  38. package/src/lib/plugin-skills.mjs +122 -0
  39. package/src/lib/prompt-loader.mjs +257 -0
  40. package/src/lib/skill-file.mjs +35 -0
  41. package/src/plugin/.claude-plugin/plugin.json +20 -0
  42. package/src/plugin/README.md +73 -0
  43. package/src/plugin/skills/bug/SKILL.md +60 -0
  44. package/src/plugin/skills/bug/model-prompt.critique.md +48 -0
  45. package/src/plugin/skills/bug/model-prompt.md +74 -0
  46. package/src/plugin/skills/decompose/SKILL.md +111 -0
  47. package/src/plugin/skills/decompose/model-prompt.critique.md +46 -0
  48. package/src/plugin/skills/decompose/model-prompt.feature.md +69 -0
  49. package/src/plugin/skills/decompose/model-prompt.rfc.md +52 -0
  50. package/src/plugin/skills/doctor/SKILL.md +51 -0
  51. package/src/plugin/skills/fix-pr/SKILL.md +130 -0
  52. package/src/plugin/skills/implement/SKILL.md +102 -0
  53. package/src/plugin/skills/info/SKILL.md +40 -0
  54. package/src/plugin/skills/issue/SKILL.md +63 -0
  55. package/src/plugin/skills/move/SKILL.md +52 -0
  56. package/src/plugin/skills/order/SKILL.md +36 -0
  57. package/src/plugin/skills/plan/SKILL.md +53 -0
  58. package/src/plugin/skills/plan/model-prompt.critique.md +44 -0
  59. package/src/plugin/skills/plan/model-prompt.md +59 -0
  60. package/src/plugin/skills/plan/reference/tech-context.md +56 -0
  61. package/src/plugin/skills/ready/SKILL.md +44 -0
  62. package/src/plugin/skills/rfc/SKILL.md +47 -0
  63. package/src/plugin/skills/setup/SKILL.md +67 -0
  64. package/src/plugin/skills/spec/SKILL.md +37 -0
  65. package/src/plugin/skills/spec/model-prompt.md +61 -0
  66. package/src/plugin/skills/story/SKILL.md +49 -0
  67. package/src/plugin/skills/task/SKILL.md +41 -0
  68. package/src/plugin/skills/triage/SKILL.md +52 -0
  69. package/src/plugin/skills/uninstall/SKILL.md +43 -0
  70. package/src/plugin/skills/update/SKILL.md +51 -0
  71. package/src/plugin/skills/workflow/SKILL.md +154 -0
  72. package/src/templates/skill/SKILL.md +54 -4
  73. package/src/templates/workflows/generate-bug.yml +36 -0
  74. package/src/templates/workflows/validate.yml +2 -1
  75. package/src/ui/wizard.mjs +5 -2
@@ -0,0 +1,160 @@
1
+ // Contexto de implementação de um Bug (RFC-004 §7.1) — puro, testável.
2
+ //
3
+ // A diferença para o contexto de Story não é o formato, é a ORDEM DO TRABALHO.
4
+ // Uma Story tem tasks a executar; um Bug tem um defeito a entender antes de
5
+ // tocar em qualquer coisa. Por isso o contexto impõe quatro fases —
6
+ // reproduzir → causa raiz → fix mínimo → teste de regressão — e a primeira
7
+ // entrega é um teste que FALHA.
8
+ //
9
+ // A ordem existe porque a alternativa é o modo de falha clássico da correção
10
+ // assistida: o executor lê o sintoma, encontra o lugar onde ele se manifesta,
11
+ // remenda ali, e o defeito reaparece na próxima entrada.
12
+
13
+ import { STAGE_DEVELOPMENT, STAGE_CODE_REVIEW, PROGRESS_IN_PROGRESS } from '../config.mjs';
14
+
15
+ /**
16
+ * Monta o markdown do contexto de um Bug (função PURA).
17
+ *
18
+ * @param {object} params
19
+ * @param {{number:number,title:string,body?:string}} params.bug
20
+ * @param {string|null} [params.bugDoc] conteúdo de docs/bugs/<slug>/bug.md
21
+ * @param {{number:number,title:string,kind:string}|null} [params.parent]
22
+ * @param {Array} [params.comments] grupos de comentários (mesmo shape do implement)
23
+ * @param {string|null} [params.codeDigest]
24
+ * @param {string[]} [params.blockedByWarnings]
25
+ * @param {string|null} [params.severity] P0–P3
26
+ * @returns {string}
27
+ */
28
+ export function buildBugContext({
29
+ bug, bugDoc = null, parent = null, comments = [], codeDigest = null,
30
+ blockedByWarnings = [], severity = null,
31
+ }) {
32
+ const lines = [];
33
+ lines.push(`# Contexto de correção — Bug #${bug.number}`);
34
+ lines.push('');
35
+ lines.push(`**Bug:** ${bug.title}`);
36
+ if (severity) lines.push(`**Severidade:** ${severity}`);
37
+ if (parent) {
38
+ lines.push(`**Item afetado:** ${parent.kind} #${parent.number} — ${parent.title}`);
39
+ }
40
+
41
+ if (bug.body && bug.body.trim()) {
42
+ lines.push('');
43
+ lines.push('## Relato original');
44
+ lines.push('');
45
+ lines.push(bug.body.trim());
46
+ }
47
+
48
+ if (blockedByWarnings.length > 0) {
49
+ lines.push('');
50
+ lines.push('## ⚠️ Dependências declaradas');
51
+ for (const w of blockedByWarnings) lines.push(`- ${w}`);
52
+ }
53
+
54
+ lines.push('');
55
+ lines.push('## Como corrigir (quatro fases, nesta ordem)');
56
+ lines.push('');
57
+ lines.push(
58
+ '> **Não comece pelo fix.** O modo de falha desta tarefa é encontrar o lugar onde o ' +
59
+ 'sintoma aparece, remendar ali, e o defeito voltar na próxima entrada. As fases 1 e 2 ' +
60
+ 'existem para impedir isso.'
61
+ );
62
+ lines.push('');
63
+ lines.push('### 1. Reproduzir');
64
+ lines.push('');
65
+ lines.push(
66
+ 'Escreva um teste que **falha** por causa deste defeito, seguindo os passos de reprodução. ' +
67
+ 'Rode-o e confirme que falha **pelo motivo certo** — um teste que falha por erro de ' +
68
+ 'digitação no próprio teste não reproduz nada. Se não conseguir reproduzir, **pare e ' +
69
+ 'reporte**: sem reprodução não há como provar que a correção funcionou.'
70
+ );
71
+ lines.push('');
72
+ lines.push('### 2. Causa raiz');
73
+ lines.push('');
74
+ lines.push(
75
+ 'Investigue até a **origem**, não até o lugar onde o erro se manifesta. Um valor nulo ' +
76
+ 'que estoura numa função raramente nasceu ali. Cite arquivo e função. Se o `bug.md` já ' +
77
+ 'traz uma causa raiz, **confirme-a contra o código** antes de aceitar — ela foi escrita ' +
78
+ 'por outro modelo, sem executar nada.'
79
+ );
80
+ lines.push('');
81
+ lines.push('### 3. Fix mínimo');
82
+ lines.push('');
83
+ lines.push(
84
+ 'Corrija a causa raiz e **apenas ela**. Um bug é o convite mais comum para refatoração ' +
85
+ 'oportunista: se você vir outros problemas no caminho, **anote-os no comentário final ' +
86
+ 'em vez de corrigi-los** — cada mudança extra aumenta o risco de uma correção que ' +
87
+ 'precisava ser cirúrgica.'
88
+ );
89
+ lines.push('');
90
+ lines.push('### 4. Teste de regressão');
91
+ lines.push('');
92
+ lines.push(
93
+ 'O teste da fase 1 agora **passa**. Garanta que ele fica no repositório e que **falharia ' +
94
+ 'de novo** se o fix fosse revertido — essa é a única prova de que ele testa o defeito, e ' +
95
+ 'não outra coisa. Rode a suíte inteira: um fix que quebra outro teste não está pronto.'
96
+ );
97
+
98
+ lines.push('');
99
+ lines.push('## Ao terminar');
100
+ lines.push('');
101
+ lines.push(
102
+ `1. Commit com a **causa raiz** na mensagem: \`fix: <o que estava errado> (#${bug.number})\`, ` +
103
+ 'e o corpo explicando a origem — não o sintoma.'
104
+ );
105
+ lines.push(`2. Abra o Pull Request com \`Fixes #${bug.number}\` no corpo.`);
106
+ lines.push(
107
+ `3. O board move sozinho: o Bug sai de **${STAGE_DEVELOPMENT}** (${PROGRESS_IN_PROGRESS}) ` +
108
+ `para **${STAGE_CODE_REVIEW}** quando o PR abre. Não mova à mão.`
109
+ );
110
+
111
+ if (bugDoc && bugDoc.trim()) {
112
+ lines.push('');
113
+ lines.push('---');
114
+ lines.push('');
115
+ lines.push('## bug.md — investigação já registrada');
116
+ lines.push('');
117
+ lines.push(
118
+ '> Escrito por IA **sem executar código**. Trate a causa raiz como hipótese a confirmar, ' +
119
+ 'não como fato.'
120
+ );
121
+ lines.push('');
122
+ lines.push(bugDoc.trim());
123
+ } else {
124
+ lines.push('');
125
+ lines.push('---');
126
+ lines.push('');
127
+ lines.push(
128
+ '> **Sem `bug.md`.** A investigação inteira é sua: o relato acima e os comentários são ' +
129
+ 'tudo o que existe.'
130
+ );
131
+ }
132
+
133
+ if (comments && comments.length > 0) {
134
+ lines.push('');
135
+ lines.push('## Comentários da issue');
136
+ lines.push('');
137
+ lines.push(
138
+ '> É onde costuma estar o que faltava no relato original — passos extras, ambiente, ' +
139
+ 'e o retorno de quem reportou. Em conflito, o comentário mais recente prevalece.'
140
+ );
141
+ for (const group of comments) {
142
+ for (const c of group.items || []) {
143
+ lines.push('');
144
+ lines.push(`**${c.author || 'desconhecido'}** (${c.createdAt}):`);
145
+ lines.push('');
146
+ lines.push(String(c.body || '').trim());
147
+ }
148
+ }
149
+ }
150
+
151
+ if (codeDigest) {
152
+ lines.push('');
153
+ lines.push('## Digest do código');
154
+ lines.push('');
155
+ lines.push(codeDigest);
156
+ }
157
+
158
+ lines.push('');
159
+ return lines.join('\n');
160
+ }
@@ -0,0 +1,51 @@
1
+ // Caminhos e checagem estrutural do bug.md — o artefato do Bug (RFC-004 §5).
2
+ //
3
+ // Fica fora de docs/features/ de propósito: um Bug NÃO é uma Feature e não gera
4
+ // spec.md/plan.md. Manter os dois na mesma pasta faria o `validate` e o
5
+ // `resolveFeaturePaths` da UI tropeçarem num diretório sem os arquivos que
6
+ // esperam.
7
+ //
8
+ // Decisão em função pura, I/O no comando — nada aqui toca o filesystem.
9
+ import path from 'node:path';
10
+ import { slugify } from './slugify.mjs';
11
+
12
+ /**
13
+ * Caminhos do bug.md a partir do título da issue (função PURA).
14
+ *
15
+ * Devolve o relativo (links, mensagem de commit) e o absoluto ancorado na raiz
16
+ * do repositório (fs) — o config é procurado subindo na árvore, e os documentos
17
+ * moram junto dele.
18
+ *
19
+ * @param {string} title título da issue (com ou sem o prefixo [BUG])
20
+ * @param {string} [root] raiz do repositório; ausente = process.cwd()
21
+ * @returns {{ slug: string, dirRel: string, fileRel: string, dirAbs: string, fileAbs: string }}
22
+ */
23
+ export function bugDocPaths(title, root = null) {
24
+ const slug = slugify(title);
25
+ const dirRel = `docs/bugs/${slug}`;
26
+ const fileRel = `${dirRel}/bug.md`;
27
+ const base = root || process.cwd();
28
+ return {
29
+ slug,
30
+ dirRel,
31
+ fileRel,
32
+ dirAbs: path.resolve(base, dirRel),
33
+ fileAbs: path.resolve(base, fileRel),
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Seções obrigatórias ausentes de um documento (função PURA).
39
+ *
40
+ * Extraída da duplicação que existia em validate.mjs (um laço idêntico para
41
+ * spec.md e outro para plan.md). Casa por `# <seção>`, o que aceita qualquer
42
+ * nível de heading (`##`, `###`) — a checagem é de PRESENÇA, não de hierarquia.
43
+ *
44
+ * @param {string} content conteúdo do documento
45
+ * @param {string[]} sections títulos obrigatórios
46
+ * @returns {string[]} os que faltam, na ordem declarada
47
+ */
48
+ export function findMissingSections(content, sections) {
49
+ const text = String(content || '');
50
+ return (sections || []).filter(section => !text.includes(`# ${section}`));
51
+ }
@@ -0,0 +1,81 @@
1
+ // Decisões da triagem de Bug (RFC-004 §4.1) — puras, testáveis, sem I/O.
2
+ //
3
+ // O portão que estas funções guardam: P2/P3 só entram na fila técnica com o
4
+ // bug.md aprovado. A razão não é burocracia — é que um bug sem causa raiz
5
+ // investigada consome o tempo do dev na investigação que a triagem deveria ter
6
+ // feito, e é aí que "corrigir o sintoma" acontece. P0/P1 são exceção porque
7
+ // esperar o documento custa mais que investigar durante a correção.
8
+
9
+ import { LABEL_BUG_APPROVED, LABEL_NEEDS_HUMAN, LABEL_CRITIQUE_FAILED } from '../config.mjs';
10
+
11
+ export const TRIAGE_ACTIONS = ['accept', 'reject', 'duplicate'];
12
+
13
+ // Severidades que dispensam o bug.md antes da fila.
14
+ const SEVERITY_WITHOUT_DOC = ['P0', 'P1'];
15
+
16
+ /**
17
+ * Resolve a ação de triagem informada (função PURA).
18
+ *
19
+ * Mesmo contrato de resolveStageName/resolveProgressName: devolve
20
+ * `{action, error}` em vez de lançar.
21
+ *
22
+ * @param {string} input
23
+ * @returns {{ action: string|null, error: string|null }}
24
+ */
25
+ export function resolveTriageAction(input) {
26
+ const key = String(input ?? '').trim().toLowerCase();
27
+ if (!key) {
28
+ return { action: null, error: `Informe a ação: ${TRIAGE_ACTIONS.join(', ')}.` };
29
+ }
30
+ const match = TRIAGE_ACTIONS.find(a => a === key);
31
+ return match
32
+ ? { action: match, error: null }
33
+ : {
34
+ action: null,
35
+ error: `Ação "${input}" não existe. Use uma de: ${TRIAGE_ACTIONS.join(', ')}.`,
36
+ };
37
+ }
38
+
39
+ /**
40
+ * O bug pode ser aceito na fila técnica? (função PURA)
41
+ *
42
+ * @param {object} params
43
+ * @param {string|null} params.severity P0–P3
44
+ * @param {string[]} params.labels labels da issue
45
+ * @returns {{ ok: boolean, error: string|null }}
46
+ */
47
+ export function canAcceptBug({ severity = null, labels = [] } = {}) {
48
+ const names = labels || [];
49
+
50
+ // Portões humanos da crítica valem para qualquer severidade: aceitar um bug
51
+ // cujo documento foi reprovado é justamente o que o portão existe para evitar.
52
+ if (names.includes(LABEL_NEEDS_HUMAN)) {
53
+ return {
54
+ ok: false,
55
+ error:
56
+ `A crítica reprovou repetidas vezes e \`${LABEL_NEEDS_HUMAN}\` está aplicada. ` +
57
+ 'Revise o bug.md e remova a label antes de aceitar.',
58
+ };
59
+ }
60
+ if (names.includes(LABEL_CRITIQUE_FAILED)) {
61
+ return {
62
+ ok: false,
63
+ error:
64
+ `A crítica adversarial apontou problemas graves (\`${LABEL_CRITIQUE_FAILED}\`). ` +
65
+ 'Corrija o bug.md e remova a label antes de aceitar.',
66
+ };
67
+ }
68
+
69
+ if (SEVERITY_WITHOUT_DOC.includes(severity)) return { ok: true, error: null };
70
+
71
+ if (!names.includes(LABEL_BUG_APPROVED)) {
72
+ return {
73
+ ok: false,
74
+ error:
75
+ `Bug ${severity || 'sem severidade'} exige o bug.md validado antes da fila técnica. ` +
76
+ 'Aplique `spec-wave:bug` para gerá-lo e `spec-wave:ready` para validá-lo — ' +
77
+ 'ou reclassifique a severidade com --severity se for urgente.',
78
+ };
79
+ }
80
+ return { ok: true, error: null };
81
+ }
@@ -5,6 +5,8 @@ import {
5
5
  DEFAULT_MAX_CRITIQUE_ATTEMPTS, labelNames,
6
6
  } from '../config.mjs';
7
7
  import { lintLanguage } from './output-lint.mjs';
8
+ import { runAgent } from '../agent/index.mjs';
9
+ import { TruncatedOutputError, isTruncationReason } from '../agent/errors.mjs';
8
10
  import { computeCost } from './usage-report.mjs';
9
11
  import { findConfigPath } from './project-root.mjs';
10
12
 
@@ -180,33 +182,11 @@ export async function withRetry(label, fn, { attempts = RETRY_ATTEMPTS, baseMs =
180
182
  throw lastErr;
181
183
  }
182
184
 
183
- // Truncamento: os dois provedores dizem explicitamente que cortaram a saída no
184
- // teto de tokens OpenRouter (formato OpenAI) em `choices[0].finish_reason`,
185
- // Anthropic em `message.stop_reason`. Ignorar esse campo é o que produzia um
186
- // documento cortado no meio de uma frase, commitado como se estivesse completo.
187
- const TRUNCATION_REASONS = new Set(['length', 'max_tokens']);
188
-
189
- /** Motivo de parada indica saída cortada no teto de tokens? (função PURA) */
190
- export function isTruncationReason(reason) {
191
- return TRUNCATION_REASONS.has(reason);
192
- }
193
-
194
- // Repetir a MESMA requisição depois de truncar dá o mesmo corte — só sobe o
195
- // custo. Por isso NÃO é marcado como transitório: o erro sobe, o Action falha
196
- // visível, destrava a label e comenta na issue o que ajustar.
197
- export class TruncatedOutputError extends Error {
198
- constructor({ provider, model, maxTokens, reason, chars }) {
199
- super(
200
- `Saída truncada pelo teto de tokens (${provider} · ${model} · max_tokens=${maxTokens} · ` +
201
- `motivo=${reason}). Foram gerados ~${chars} caracteres antes do corte. ` +
202
- 'Aumente `ai.maxTokens` (ou `ai.maxTokensByAction`) no .spec-wave.json, ou reduza o ' +
203
- 'tamanho da issue de origem. O documento NÃO foi gravado — um documento cortado ' +
204
- 'passaria na validação de seções e valeria menos que nenhum.'
205
- );
206
- this.name = 'TruncatedOutputError';
207
- this.truncated = true;
208
- }
209
- }
185
+ // `TruncatedOutputError` e `isTruncationReason` saíram daqui para
186
+ // `agent/errors.mjs` quando os backends portados passaram a precisar deles.
187
+ // Re-exportados para não quebrar os 5 consumidores e os testes que importam
188
+ // deste módulo.
189
+ export { TruncatedOutputError, isTruncationReason };
210
190
 
211
191
  // Os parâmetros de sampling foram REMOVIDOS a partir do Claude Opus 4.7 (vale
212
192
  // para 4.8 e 5, Sonnet 5, Fable 5 e Mythos 5): enviar temperature/top_p/top_k
@@ -234,67 +214,11 @@ export function supportsStrictSchema(model) {
234
214
  return MODELS_WITH_STRICT_SCHEMA.test(model || '');
235
215
  }
236
216
 
237
- /**
238
- * Corpo EXATO de `client.messages.create` (função PURA testável sem HTTP).
239
- *
240
- * Com `schema`, força a ferramenta via tool_choice: o modelo não tem como
241
- * responder em texto livre, que é o que produzia JSON sujo na crítica.
242
- * `disable_parallel_tool_use` garante no máximo um bloco tool_use.
243
- */
244
- export function buildAnthropicRequest({
245
- ai, systemPrompt, userContent, temperature, maxTokens, schema, strict,
246
- }) {
247
- return {
248
- model: ai.model,
249
- max_tokens: maxTokens,
250
- // Omitida nos modelos que removeram sampling (Opus 4.7+, Sonnet 5, Fable 5):
251
- // enviá-la devolve 400.
252
- ...(temperature === undefined ? {} : { temperature }),
253
- messages: [{ role: 'user', content: userContent }],
254
- system: systemPrompt,
255
- ...(schema ? {
256
- tools: [{
257
- name: schema.name,
258
- description: schema.description,
259
- input_schema: schema.jsonSchema,
260
- ...(strict ? { strict: true } : {}),
261
- }],
262
- tool_choice: { type: 'tool', name: schema.name, disable_parallel_tool_use: true },
263
- } : {}),
264
- };
265
- }
217
+ // `buildAnthropicRequest` e `buildOpenRouterBody` foram REMOVIDOS: quem monta
218
+ // a requisição agora é o motor (`src/agent/`). No backend openrouter isso é
219
+ // `buildChatCompletionBody`; no anthropic, o subprocesso do Claude Code monta
220
+ // a própria este processo não fala com a API da Anthropic.
266
221
 
267
- /**
268
- * Corpo EXATO do POST da OpenRouter (função PURA — testável sem HTTP).
269
- *
270
- * `provider.require_parameters` é obrigatório junto do response_format: sem ele o
271
- * roteador escolhe endpoints que IGNORAM o schema e devolvem prosa, e o erro só
272
- * apareceria no JSON.parse, três retries depois. Com ele, a recusa é imediata e
273
- * a mensagem diz o que trocar.
274
- */
275
- export function buildOpenRouterBody({
276
- ai, systemPrompt, userContent, temperature, maxTokens, schema,
277
- }) {
278
- return {
279
- model: ai.model,
280
- max_tokens: maxTokens,
281
- // Omitida nos modelos que removeram sampling (Opus 4.7+, Sonnet 5, Fable 5).
282
- ...(temperature === undefined ? {} : { temperature }),
283
- messages: [
284
- { role: 'system', content: systemPrompt },
285
- { role: 'user', content: userContent },
286
- ],
287
- // Pede o bloco `usage` completo na resposta (inclui `cost` em USD).
288
- usage: { include: true },
289
- ...(schema ? {
290
- response_format: {
291
- type: 'json_schema',
292
- json_schema: { name: schema.name, strict: true, schema: schema.jsonSchema },
293
- },
294
- provider: { require_parameters: true },
295
- } : {}),
296
- };
297
- }
298
222
 
299
223
  // temperature padrão 0.2 (RFC-002 §5): "Determinism over Creativity". Pode ser
300
224
  // sobrescrita por chamada via opts, mas o default cobre spec/plan/decompose.
@@ -333,11 +257,14 @@ export async function generateDocument(systemPrompt, userContent, opts = {}) {
333
257
  const callOpts = {
334
258
  temperature: sendTemperature ? temperature : undefined,
335
259
  maxTokens,
260
+ action: opts.action,
261
+ // É aqui que o motor paga por si: o modelo passa a poder LER o
262
+ // repositório antes de escrever o documento. É o que os blocos
263
+ // `requires-tools` dos model-prompt sempre descreveram.
264
+ tools: opts.tools ?? ['Read', 'Glob', 'Grep'],
336
265
  };
337
266
  const { text, usage } = await withRetry(`Geração via ${ai.provider}`, () =>
338
- ai.provider === 'openrouter'
339
- ? generateWithOpenRouter(system, userContent, ai, callOpts)
340
- : generateWithAnthropic(system, userContent, ai, callOpts));
267
+ generateViaEngine(system, userContent, ai, callOpts));
341
268
  inputTokens += usage.inputTokens;
342
269
  outputTokens += usage.outputTokens;
343
270
  if (typeof usage.cost === 'number') cost = (cost ?? 0) + usage.cost;
@@ -441,14 +368,13 @@ export async function generateStructured(systemPrompt, userContent, opts = {}) {
441
368
  maxTokens,
442
369
  schema,
443
370
  strict,
371
+ action: opts.action,
444
372
  };
445
373
 
446
374
  // A validação roda DENTRO do retry de propósito: um payload fora do schema é
447
375
  // marcado como transitório, e repetir a mesma requisição costuma resolver.
448
376
  const { value, usage } = await withRetry(`Geração estruturada via ${ai.provider}`, async () => {
449
- const res = ai.provider === 'openrouter'
450
- ? await generateWithOpenRouter(systemPrompt, userContent, ai, callOpts)
451
- : await generateWithAnthropic(systemPrompt, userContent, ai, callOpts);
377
+ const res = await generateViaEngine(systemPrompt, userContent, ai, callOpts);
452
378
  return { value: schema.validate ? schema.validate(res.json) : res.json, usage: res.usage };
453
379
  }, opts.retry);
454
380
 
@@ -506,181 +432,72 @@ export function extractOpenRouterUsage(usage) {
506
432
  };
507
433
  }
508
434
 
509
- async function generateWithAnthropic(
510
- systemPrompt, userContent, ai, { temperature, maxTokens, schema, strict }
435
+ /**
436
+ * Ponte para o motor do agente (`src/agent/`).
437
+ *
438
+ * Substitui as duas funções que falavam com a API diretamente
439
+ * (`generateWithAnthropic` / `generateWithOpenRouter`). O motor foi portado do
440
+ * agent-cli e traz o que o caminho direto não tinha: loop agêntico com
441
+ * Read/Glob/Grep de verdade e instrumentação Langfuse.
442
+ *
443
+ * Tudo o que já estava CERTO neste módulo continua acima desta linha —
444
+ * resolução de modelo por ação/label, retry, lint de idioma, contagem de uso.
445
+ * O motor cuida da chamada; este arquivo segue dono da política.
446
+ *
447
+ * O prompt do sistema vira `systemPromptAppend` de propósito: no backend
448
+ * anthropic ele é ANEXADO ao preset `claude_code`, preservando a competência de
449
+ * uso de tools do harness em vez de substituí-la.
450
+ *
451
+ * @returns {Promise<{text: string, json?: object, usage: object}>}
452
+ */
453
+ async function generateViaEngine(
454
+ systemPrompt, userContent, ai, { temperature, maxTokens, schema, strict, action, tools },
511
455
  ) {
512
- const apiKey = process.env.ANTHROPIC_API_KEY;
513
- if (!apiKey) {
514
- throw new Error(
515
- 'ANTHROPIC_API_KEY not set.\n' +
516
- 'Add it as a GitHub Actions secret or set it in your environment.'
517
- );
518
- }
456
+ const result = await runAgent(userContent, {
457
+ provider: ai.provider,
458
+ model: ai.model,
459
+ systemPromptAppend: systemPrompt,
460
+ // Sessão/usuário alimentam o agrupamento das traces no Langfuse. Sem
461
+ // telemetria configurada nada disso sai do processo.
462
+ sessionId: `spec-wave-${action || 'run'}`,
463
+ userId: 'spec-wave',
464
+ ...(tools ? { tools } : {}),
465
+ ...(maxTokens ? { maxTokens } : {}),
466
+ ...(temperature !== undefined ? { temperature } : {}),
467
+ ...(schema
468
+ ? { responseSchema: { ...schema, strict: strict === true } }
469
+ : {}),
470
+ ...(action ? { action, extraTags: [action] } : {}),
471
+ cwd: ai.cwd ?? process.cwd(),
472
+ quiet: true,
473
+ });
519
474
 
520
- const client = new Anthropic({ apiKey });
521
- const message = await client.messages.create(buildAnthropicRequest({
522
- ai, systemPrompt, userContent, temperature, maxTokens, schema, strict,
523
- }));
524
-
525
- // A resposta nem sempre começa com um bloco de texto (recusa, resposta vazia):
526
- // `content[0].text` cru virava TypeError com mensagem inútil.
527
- const text = (message.content || [])
528
- .filter((block) => block.type === 'text')
529
- .map((block) => block.text)
530
- .join('');
531
- // Com tool_choice forçado a resposta traz um bloco tool_use e NENHUM bloco de
532
- // texto — daí a necessidade do branch dedicado antes da checagem de texto vazio.
533
- const tool = (message.content || [])
534
- .find((block) => block.type === 'tool_use' && block.name === schema?.name);
535
-
536
- if (message.stop_reason === 'refusal') {
537
- throw new Error(
538
- `A Anthropic recusou a requisição (stop_reason=refusal` +
539
- `${message.stop_details?.category ? `, categoria=${message.stop_details.category}` : ''}). ` +
540
- 'Revise o conteúdo da issue de origem.'
541
- );
542
- }
543
- if (message.stop_reason === 'model_context_window_exceeded') {
544
- // Estouro na ENTRADA — subir max_tokens não resolve; o que precisa encolher
545
- // é a issue/contexto enviado.
546
- throw new Error(
547
- `Contexto de entrada excedido (${ai.model}). Reduza o tamanho da issue de origem ` +
548
- 'ou do tech_context antes de repetir.'
549
- );
550
- }
551
- if (isTruncationReason(message.stop_reason)) {
552
- throw new TruncatedOutputError({
553
- provider: 'anthropic',
554
- model: ai.model,
555
- maxTokens,
556
- reason: message.stop_reason,
557
- // Sob tool call forçado não há bloco de texto: medir `text.length` diria
558
- // sempre "~0 caracteres antes do corte".
559
- chars: schema ? JSON.stringify(tool?.input ?? '').length : text.length,
560
- });
561
- }
475
+ const usage = {
476
+ inputTokens: result.usage?.inputTokens ?? 0,
477
+ outputTokens: result.usage?.outputTokens ?? 0,
478
+ cost: result.costUsd,
479
+ };
562
480
 
563
- // ATENÇÃO: este branch precisa vir ANTES da checagem de `!text` — uma resposta
564
- // com tool call forçado não tem bloco de texto, e cair no `!text` faria toda
565
- // crítica queimar os 3 retries com a mensagem errada.
566
481
  if (schema) {
567
- if (!tool || typeof tool.input !== 'object' || tool.input === null) {
482
+ if (result.structured === null || result.structured === undefined) {
568
483
  const err = new Error(
569
- `A Anthropic não devolveu a ferramenta \`${schema.name}\` ` +
570
- `(stop_reason=${message.stop_reason}). Conteúdo: ${text.slice(0, 200) || '(vazio)'}`
484
+ `O modelo ${ai.model} não devolveu a saída estruturada "${schema.name}" ` +
485
+ `(subtype=${result.resultSubtype}).`
571
486
  );
572
- err.transient = true; // ferramenta forçada: repetir a requisição costuma resolver
487
+ err.transient = true; // repetir a mesma requisição costuma resolver
573
488
  throw err;
574
489
  }
575
- return {
576
- text: JSON.stringify(tool.input),
577
- json: tool.input,
578
- usage: extractAnthropicUsage(message.usage),
579
- };
490
+ return { text: result.outputText, json: result.structured, usage };
580
491
  }
581
492
 
493
+ const text = stripReasoning(result.outputText || '');
582
494
  if (!text) {
583
495
  const err = new Error(
584
- `A Anthropic retornou resposta sem texto (stop_reason=${message.stop_reason}).`
585
- );
586
- err.transient = true;
587
- throw err;
588
- }
589
-
590
- return { text, usage: extractAnthropicUsage(message.usage) };
591
- }
592
-
593
- async function generateWithOpenRouter(
594
- systemPrompt, userContent, ai, { temperature, maxTokens, schema }
595
- ) {
596
- const apiKey = process.env.OPENROUTER_API_KEY;
597
- if (!apiKey) {
598
- throw new Error(
599
- 'OPENROUTER_API_KEY not set.\n' +
600
- 'Add it as a GitHub Actions secret or set it in your environment.'
601
- );
602
- }
603
-
604
- const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
605
- method: 'POST',
606
- headers: {
607
- Authorization: `Bearer ${apiKey}`,
608
- 'Content-Type': 'application/json',
609
- 'HTTP-Referer': 'https://github.com/moacsjr/spec-wave',
610
- 'X-Title': 'spec-wave',
611
- },
612
- body: JSON.stringify(buildOpenRouterBody({
613
- ai, systemPrompt, userContent, temperature, maxTokens, schema,
614
- })),
615
- });
616
-
617
- if (!res.ok) {
618
- const body = await res.text();
619
- const err = new Error(
620
- `OpenRouter API ${res.status}: ${body}` +
621
- // require_parameters faz o roteador recusar quando nenhum provedor do modelo
622
- // implementa response_format — erro de CONFIGURAÇÃO, não de rede.
623
- (schema && (res.status === 404 || res.status === 400)
624
- ? `\nNenhum provedor de ${ai.model} suporta response_format/json_schema. ` +
625
- 'Aponte `ai.models.critique` no .spec-wave.json para um modelo com saída estruturada.'
626
- : '')
627
- );
628
- err.status = res.status;
629
- throw err;
630
- }
631
-
632
- // Um 200 com corpo vazio ou cortado acontece em gerações longas. `res.json()`
633
- // cru lançaria "Unexpected end of JSON input" — mensagem que não diz nada a
634
- // quem está olhando o board. Lê como texto, reporta o que veio e marca como
635
- // transitória para o retry pegar.
636
- const raw = await res.text();
637
- let data;
638
- try {
639
- data = JSON.parse(raw);
640
- } catch {
641
- const err = new Error(
642
- `OpenRouter devolveu ${res.status} com corpo inválido (${raw.length} bytes): ` +
643
- `${raw.slice(0, 200) || '(vazio)'}`
496
+ `O provider ${ai.provider} retornou resposta sem texto ` +
497
+ `(subtype=${result.resultSubtype}, turnos=${result.numTurns}).`
644
498
  );
645
499
  err.transient = true;
646
500
  throw err;
647
501
  }
648
-
649
- const choice = data?.choices?.[0];
650
- const content = stripReasoning(choice?.message?.content || '');
651
-
652
- // finish_reason='length' = cortado no teto. Checado ANTES do conteúdo vazio:
653
- // um corte durante o raciocínio devolve texto vazio, e "resposta vazia" (que
654
- // é tratada como transitória) esconderia a causa real por trás de 3 retries.
655
- if (isTruncationReason(choice?.finish_reason)) {
656
- throw new TruncatedOutputError({
657
- provider: 'openrouter',
658
- model: ai.model,
659
- maxTokens,
660
- // native_finish_reason preserva o motivo cru do provedor upstream.
661
- reason: `${choice.finish_reason}${choice.native_finish_reason ? `/${choice.native_finish_reason}` : ''}`,
662
- chars: content.length,
663
- });
664
- }
665
- if (!content) {
666
- const err = new Error(`OpenRouter retornou resposta vazia: ${JSON.stringify(data)}`);
667
- err.transient = true;
668
- throw err;
669
- }
670
-
671
- if (schema) {
672
- const raw = stripOuterFence(content);
673
- try {
674
- return { text: raw, json: JSON.parse(raw), usage: extractOpenRouterUsage(data.usage) };
675
- } catch {
676
- const err = new Error(
677
- `O modelo ${ai.model} ignorou response_format e devolveu texto não-JSON ` +
678
- `(${raw.length} bytes): ${raw.slice(0, 200)}`
679
- );
680
- err.transient = true; // ≠ truncamento: repetir pode dar certo
681
- throw err;
682
- }
683
- }
684
-
685
- return { text: content, usage: extractOpenRouterUsage(data.usage) };
502
+ return { text, usage };
686
503
  }