@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.
- package/README.md +1 -0
- package/bin/spec-wave.mjs +41 -2
- package/package.json +8 -2
- package/src/agent/anthropic-agent.mjs +337 -0
- package/src/agent/errors.mjs +33 -0
- package/src/agent/index.mjs +108 -0
- package/src/agent/openrouter-agent.mjs +378 -0
- package/src/agent/run-types.mjs +59 -0
- package/src/agent/telemetry.mjs +54 -0
- package/src/agent/tools.mjs +452 -0
- package/src/agent/tracing.mjs +106 -0
- package/src/api/github-rest.mjs +8 -0
- package/src/commands/bug.mjs +8 -0
- package/src/commands/code-review.mjs +45 -4
- package/src/commands/decompose.mjs +11 -49
- package/src/commands/dev-agent.mjs +3 -3
- package/src/commands/doctor.mjs +77 -6
- package/src/commands/generate-bug.mjs +195 -0
- package/src/commands/generate-plan.mjs +6 -20
- package/src/commands/generate-spec.mjs +6 -22
- package/src/commands/implement.mjs +105 -2
- package/src/commands/init.mjs +3 -3
- package/src/commands/install-skill.mjs +72 -16
- package/src/commands/issue.mjs +9 -7
- package/src/commands/move.mjs +11 -1
- package/src/commands/qa.mjs +23 -2
- package/src/commands/refresh.mjs +145 -5
- package/src/commands/triage.mjs +174 -0
- package/src/commands/update.mjs +16 -3
- package/src/commands/validate.mjs +82 -10
- package/src/config.mjs +159 -1
- package/src/lib/bug-context.mjs +160 -0
- package/src/lib/bug-doc.mjs +51 -0
- package/src/lib/bug-triage.mjs +81 -0
- package/src/lib/claude.mjs +71 -254
- package/src/lib/critique.mjs +43 -30
- package/src/lib/implement-board.mjs +12 -1
- package/src/lib/plugin-skills.mjs +122 -0
- package/src/lib/prompt-loader.mjs +257 -0
- package/src/lib/skill-file.mjs +35 -0
- package/src/plugin/.claude-plugin/plugin.json +20 -0
- package/src/plugin/README.md +73 -0
- package/src/plugin/skills/bug/SKILL.md +60 -0
- package/src/plugin/skills/bug/model-prompt.critique.md +48 -0
- package/src/plugin/skills/bug/model-prompt.md +74 -0
- package/src/plugin/skills/decompose/SKILL.md +111 -0
- package/src/plugin/skills/decompose/model-prompt.critique.md +46 -0
- package/src/plugin/skills/decompose/model-prompt.feature.md +69 -0
- package/src/plugin/skills/decompose/model-prompt.rfc.md +52 -0
- package/src/plugin/skills/doctor/SKILL.md +51 -0
- package/src/plugin/skills/fix-pr/SKILL.md +130 -0
- package/src/plugin/skills/implement/SKILL.md +102 -0
- package/src/plugin/skills/info/SKILL.md +40 -0
- package/src/plugin/skills/issue/SKILL.md +63 -0
- package/src/plugin/skills/move/SKILL.md +52 -0
- package/src/plugin/skills/order/SKILL.md +36 -0
- package/src/plugin/skills/plan/SKILL.md +53 -0
- package/src/plugin/skills/plan/model-prompt.critique.md +44 -0
- package/src/plugin/skills/plan/model-prompt.md +59 -0
- package/src/plugin/skills/plan/reference/tech-context.md +56 -0
- package/src/plugin/skills/ready/SKILL.md +44 -0
- package/src/plugin/skills/rfc/SKILL.md +47 -0
- package/src/plugin/skills/setup/SKILL.md +67 -0
- package/src/plugin/skills/spec/SKILL.md +37 -0
- package/src/plugin/skills/spec/model-prompt.md +61 -0
- package/src/plugin/skills/story/SKILL.md +49 -0
- package/src/plugin/skills/task/SKILL.md +41 -0
- package/src/plugin/skills/triage/SKILL.md +52 -0
- package/src/plugin/skills/uninstall/SKILL.md +43 -0
- package/src/plugin/skills/update/SKILL.md +51 -0
- package/src/plugin/skills/workflow/SKILL.md +154 -0
- package/src/templates/skill/SKILL.md +54 -4
- package/src/templates/workflows/generate-bug.yml +36 -0
- package/src/templates/workflows/validate.yml +2 -1
- 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
|
+
}
|
package/src/lib/claude.mjs
CHANGED
|
@@ -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
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
510
|
-
|
|
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
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
|
521
|
-
|
|
522
|
-
|
|
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 (
|
|
482
|
+
if (result.structured === null || result.structured === undefined) {
|
|
568
483
|
const err = new Error(
|
|
569
|
-
`
|
|
570
|
-
`(
|
|
484
|
+
`O modelo ${ai.model} não devolveu a saída estruturada "${schema.name}" ` +
|
|
485
|
+
`(subtype=${result.resultSubtype}).`
|
|
571
486
|
);
|
|
572
|
-
err.transient = true; //
|
|
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
|
-
`
|
|
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
|
}
|