@spec-wave/cli 0.10.0 → 0.11.1
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 +8 -0
- package/bin/spec-wave.mjs +20 -0
- package/package.json +2 -2
- package/src/api/github-graphql.mjs +4 -0
- package/src/api/github-rest.mjs +13 -0
- package/src/commands/decompose.mjs +183 -19
- package/src/commands/dev-agent.mjs +568 -0
- package/src/commands/doctor.mjs +11 -0
- package/src/commands/generate-plan.mjs +21 -1
- package/src/commands/generate-spec.mjs +24 -1
- package/src/commands/validate.mjs +9 -0
- package/src/config.mjs +5 -0
- package/src/lib/claude.mjs +216 -19
- package/src/lib/critique.mjs +5 -1
- package/src/lib/doc-completeness.mjs +54 -0
- package/src/lib/force.mjs +34 -0
- package/src/templates/agent/dev.specwave.agent.plist +17 -0
- package/src/templates/agent/spec-wave-agent.service +18 -0
- package/src/templates/skill/SKILL.md +60 -7
|
@@ -5,6 +5,7 @@ import { getIssue, removeLabel, commentOnIssue } from '../api/github-rest.mjs';
|
|
|
5
5
|
import { generateDocument } from '../lib/claude.mjs';
|
|
6
6
|
import { recordUsage } from '../lib/usage-report.mjs';
|
|
7
7
|
import { slugify } from '../lib/slugify.mjs';
|
|
8
|
+
import { isForced, consumeForceLabel } from '../lib/force.mjs';
|
|
8
9
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
9
10
|
import { allowsSpecPlan, SPEC_PLAN_EXCLUDED_TYPES, TARGET_LANGUAGE } from '../config.mjs';
|
|
10
11
|
|
|
@@ -41,7 +42,7 @@ Regras:
|
|
|
41
42
|
- Seja específico e detalhado em cada seção.
|
|
42
43
|
- Responda APENAS com o conteúdo do spec.md, sem texto adicional.`;
|
|
43
44
|
|
|
44
|
-
export async function generateSpec({ issueNumber }) {
|
|
45
|
+
export async function generateSpec({ issueNumber, force = false }) {
|
|
45
46
|
const token = await resolveToken();
|
|
46
47
|
const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
47
48
|
|
|
@@ -70,6 +71,13 @@ export async function generateSpec({ issueNumber }) {
|
|
|
70
71
|
return;
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
// A geração de spec/plan não tem guard — re-acionar a label já regera e
|
|
75
|
+
// sobrescreve o arquivo. O force existe por simetria com o decompose (e para
|
|
76
|
+
// a label ser consumida quando aplicada aos três de uma vez).
|
|
77
|
+
const forced = isForced({ labels: issue.labels || [], flag: force });
|
|
78
|
+
await consumeForceLabel(token, owner, repo, parseInt(issueNumber, 10));
|
|
79
|
+
if (forced) console.log('Modo forçado ativo — spec.md será regerado (sobrescreve o existente).');
|
|
80
|
+
|
|
73
81
|
const slug = slugify(issue.title);
|
|
74
82
|
const featureDir = `docs/features/${slug}`;
|
|
75
83
|
const filePath = `${featureDir}/spec.md`;
|
|
@@ -125,6 +133,21 @@ export async function generateSpec({ issueNumber }) {
|
|
|
125
133
|
);
|
|
126
134
|
|
|
127
135
|
console.log(`spec.md criado em: ${filePath}`);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
// Sem isto a label de gatilho fica aplicada — e como o workflow dispara em
|
|
138
|
+
// `issues: [labeled]`, re-adicionar uma label já presente não emite evento:
|
|
139
|
+
// a issue vira um beco sem saída, sem comentário e sem sinal no board.
|
|
140
|
+
// Espelha o tratamento do tipo não suportado (acima): remove o gatilho e
|
|
141
|
+
// reporta na issue. O erro segue propagando para o Action falhar visível.
|
|
142
|
+
await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:spec').catch(() => {});
|
|
143
|
+
await commentOnIssue(
|
|
144
|
+
token, owner, repo, parseInt(issueNumber, 10),
|
|
145
|
+
`❌ **Falha ao gerar a spec.md**\n\n` +
|
|
146
|
+
`\`\`\`\n${err.message}\n\`\`\`\n\n` +
|
|
147
|
+
`A label \`spec-wave:spec\` foi removida para destravar o gatilho — ` +
|
|
148
|
+
`adicione-a de novo para tentar outra vez.`
|
|
149
|
+
).catch(() => {});
|
|
150
|
+
throw err;
|
|
128
151
|
} finally {
|
|
129
152
|
// Best-effort: nunca propaga erro (ver recordUsage).
|
|
130
153
|
await recordUsage({ token, owner, repo, issueNumber: parseInt(issueNumber, 10), entries: usageEntries });
|
|
@@ -4,6 +4,7 @@ import { resolveToken } from '../api/auth.mjs';
|
|
|
4
4
|
import { getIssue, removeLabel, addLabel, commentOnIssue } from '../api/github-rest.mjs';
|
|
5
5
|
import { slugify } from '../lib/slugify.mjs';
|
|
6
6
|
import { CONFIG_FILE, LABEL_CRITIQUE_FAILED, REQUIRED_PLAN_SECTIONS, REQUIRED_SPEC_SECTIONS } from '../config.mjs';
|
|
7
|
+
import { findIncompleteDocSigns } from '../lib/doc-completeness.mjs';
|
|
7
8
|
|
|
8
9
|
export async function validate({ issueNumber }) {
|
|
9
10
|
const token = await resolveToken();
|
|
@@ -49,6 +50,9 @@ export async function validate({ issueNumber }) {
|
|
|
49
50
|
errors.push(`❌ Seção obrigatória ausente no plan.md: **${section}**`);
|
|
50
51
|
}
|
|
51
52
|
}
|
|
53
|
+
for (const problem of findIncompleteDocSigns(planContent)) {
|
|
54
|
+
errors.push(`❌ \`plan.md\` parece incompleto: ${problem}`);
|
|
55
|
+
}
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
// Check spec.md
|
|
@@ -62,6 +66,11 @@ export async function validate({ issueNumber }) {
|
|
|
62
66
|
errors.push(`❌ Seção obrigatória ausente no spec.md: **${section}**`);
|
|
63
67
|
}
|
|
64
68
|
}
|
|
69
|
+
// Seções presentes não garantem documento completo: um corte dentro da
|
|
70
|
+
// última seção passa na checagem acima (foi o caso da EP2-F13).
|
|
71
|
+
for (const problem of findIncompleteDocSigns(specContent)) {
|
|
72
|
+
errors.push(`❌ \`spec.md\` parece incompleto: ${problem}`);
|
|
73
|
+
}
|
|
65
74
|
}
|
|
66
75
|
|
|
67
76
|
// Remove trigger label
|
package/src/config.mjs
CHANGED
|
@@ -183,6 +183,10 @@ export const PRIORITY_LABELS = [
|
|
|
183
183
|
// Labels de estado gravadas pelas automações (não são gatilhos do usuário).
|
|
184
184
|
export const LABEL_CRITIQUE_FAILED = 'spec-wave:critique-failed';
|
|
185
185
|
export const LABEL_DECOMPOSED = 'spec-wave:decomposed';
|
|
186
|
+
// Modificador (não é gatilho — sozinha não dispara workflow nenhum): quando
|
|
187
|
+
// presente junto de uma label de gatilho, manda o comando re-executar a etapa
|
|
188
|
+
// ignorando os guards. É consumida (removida) pelo run que a leu.
|
|
189
|
+
export const LABEL_FORCE = 'spec-wave:force';
|
|
186
190
|
|
|
187
191
|
export const TRIGGER_LABELS = [
|
|
188
192
|
{ name: 'spec-wave:spec', color: 'BFD4F2', description: 'Gerar spec.md via GitHub Action' },
|
|
@@ -192,6 +196,7 @@ export const TRIGGER_LABELS = [
|
|
|
192
196
|
{ name: 'spec-wave:decompose', color: 'BFD4F2', description: 'Decompor em Stories e Tasks' },
|
|
193
197
|
{ name: LABEL_CRITIQUE_FAILED, color: 'B60205', description: 'Crítica adversarial apontou contradições graves' },
|
|
194
198
|
{ name: LABEL_DECOMPOSED, color: 'EDEDED', description: 'Feature já decomposta em Stories e Tasks' },
|
|
199
|
+
{ name: LABEL_FORCE, color: 'D93F0B', description: 'Re-executa a etapa ignorando os guards (consumida no run)' },
|
|
195
200
|
];
|
|
196
201
|
|
|
197
202
|
export const ALL_LABELS = [...TYPE_LABELS, ...PRIORITY_LABELS, ...TRIGGER_LABELS];
|
package/src/lib/claude.mjs
CHANGED
|
@@ -5,19 +5,30 @@ import { CONFIG_FILE, getProvider, DEFAULT_PROVIDER } from '../config.mjs';
|
|
|
5
5
|
import { lintLanguage } from './output-lint.mjs';
|
|
6
6
|
import { computeCost } from './usage-report.mjs';
|
|
7
7
|
|
|
8
|
+
// Teto de saída padrão. O valor anterior (8192) truncava specs reais: um
|
|
9
|
+
// documento de ~20 KB em pt-BR já custa ~6k tokens, e modelos com raciocínio
|
|
10
|
+
// (Opus 4.7+, deepseek-r1) gastam o restante "pensando" ANTES de escrever. O
|
|
11
|
+
// teto de saída dos modelos atuais é 128k — 32k deixa folga sem virar cheque em
|
|
12
|
+
// branco. Ajustável por `ai.maxTokens` / `ai.maxTokensByAction`.
|
|
13
|
+
export const DEFAULT_MAX_TOKENS = 32768;
|
|
14
|
+
|
|
8
15
|
/**
|
|
9
|
-
* Resolve provider/modelo de IA (função PURA — testável sem
|
|
16
|
+
* Resolve provider/modelo/teto de tokens de IA (função PURA — testável sem
|
|
17
|
+
* process.env nem fs).
|
|
10
18
|
*
|
|
11
19
|
* Precedência do modelo: env.SPEC_WAVE_MODEL → fileAi.models[action] →
|
|
12
20
|
* fileAi.model → default do provider. Provider: env.SPEC_WAVE_PROVIDER →
|
|
13
21
|
* fileAi.provider → default. Assim uma ação específica (ex.: critique) pode
|
|
14
22
|
* usar modelo próprio via bloco `ai.models` do .spec-wave.json.
|
|
15
23
|
*
|
|
24
|
+
* maxTokens segue a mesma forma: env.SPEC_WAVE_MAX_TOKENS →
|
|
25
|
+
* fileAi.maxTokensByAction[action] → fileAi.maxTokens → DEFAULT_MAX_TOKENS.
|
|
26
|
+
*
|
|
16
27
|
* @param {object} params
|
|
17
28
|
* @param {object} [params.env] objeto tipo process.env
|
|
18
29
|
* @param {object} [params.fileAi] bloco `ai` do .spec-wave.json
|
|
19
30
|
* @param {string} [params.action] ação de IA (ver AI_ACTIONS em config.mjs)
|
|
20
|
-
* @returns {{ provider: string, model: string, secret: string, pricing: object|null }}
|
|
31
|
+
* @returns {{ provider: string, model: string, secret: string, pricing: object|null, maxTokens: number }}
|
|
21
32
|
*/
|
|
22
33
|
export function resolveAiConfig({ env = {}, fileAi = {}, action } = {}) {
|
|
23
34
|
const provider = (env.SPEC_WAVE_PROVIDER || fileAi.provider || DEFAULT_PROVIDER).toLowerCase();
|
|
@@ -29,7 +40,24 @@ export function resolveAiConfig({ env = {}, fileAi = {}, action } = {}) {
|
|
|
29
40
|
// pricing: tabela `ai.pricing` do .spec-wave.json ({ [model]: { input,
|
|
30
41
|
// output } } em USD/1M tokens) — usada para estimar custo quando o provider
|
|
31
42
|
// não devolve o valor (Anthropic).
|
|
32
|
-
|
|
43
|
+
const maxTokens = positiveInt(env.SPEC_WAVE_MAX_TOKENS)
|
|
44
|
+
?? (action ? positiveInt(fileAi.maxTokensByAction?.[action]) : undefined)
|
|
45
|
+
?? positiveInt(fileAi.maxTokens)
|
|
46
|
+
?? DEFAULT_MAX_TOKENS;
|
|
47
|
+
return {
|
|
48
|
+
provider: meta.value,
|
|
49
|
+
model,
|
|
50
|
+
secret: meta.secret,
|
|
51
|
+
pricing: fileAi.pricing || null,
|
|
52
|
+
maxTokens,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Config vinda de JSON/env pode trazer string, zero ou lixo — só aceita inteiro
|
|
57
|
+
// positivo, senão cai na próxima fonte da precedência.
|
|
58
|
+
function positiveInt(value) {
|
|
59
|
+
const n = Number(value);
|
|
60
|
+
return Number.isInteger(n) && n > 0 ? n : undefined;
|
|
33
61
|
}
|
|
34
62
|
|
|
35
63
|
// Resolve o provider/modelo de IA a partir do .spec-wave.json (gravado pelo init
|
|
@@ -49,6 +77,87 @@ function resolveAi(action) {
|
|
|
49
77
|
return resolveAiConfig({ env: process.env, fileAi, action });
|
|
50
78
|
}
|
|
51
79
|
|
|
80
|
+
// Retry de falha transitória do provedor. Sem isto, um corpo cortado numa
|
|
81
|
+
// geração de 90s derruba o Action inteiro e a issue fica sem spec — o custo de
|
|
82
|
+
// esperar alguns segundos é irrisório perto de refazer o ciclo à mão.
|
|
83
|
+
export const RETRY_ATTEMPTS = 3;
|
|
84
|
+
const RETRY_BASE_MS = 2000;
|
|
85
|
+
|
|
86
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
87
|
+
|
|
88
|
+
// Transitório = vale a pena repetir a MESMA requisição. Erro de configuração
|
|
89
|
+
// (401, 400, chave ausente) não entra aqui: repetir só atrasa a mensagem útil.
|
|
90
|
+
export function isTransientProviderError(err) {
|
|
91
|
+
if (!err) return false;
|
|
92
|
+
if (err.transient) return true; // marcado na origem (corpo inválido/vazio)
|
|
93
|
+
const status = err.status ?? err.statusCode;
|
|
94
|
+
if (status === 429 || (status >= 500 && status < 600)) return true;
|
|
95
|
+
// undici/fetch: falha de rede vem como TypeError com cause
|
|
96
|
+
return /fetch failed|ECONNRESET|ETIMEDOUT|EAI_AGAIN|socket hang up|network|aborted/i
|
|
97
|
+
.test(err.message || '');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function withRetry(label, fn, { attempts = RETRY_ATTEMPTS, baseMs = RETRY_BASE_MS } = {}) {
|
|
101
|
+
let lastErr;
|
|
102
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
103
|
+
try {
|
|
104
|
+
return await fn();
|
|
105
|
+
} catch (err) {
|
|
106
|
+
lastErr = err;
|
|
107
|
+
if (attempt === attempts || !isTransientProviderError(err)) break;
|
|
108
|
+
const delayMs = baseMs * 2 ** (attempt - 1); // 2s, 4s, 8s…
|
|
109
|
+
console.warn(
|
|
110
|
+
`${label}: falha transitória na tentativa ${attempt}/${attempts} (${err.message}) — ` +
|
|
111
|
+
`repetindo em ${delayMs / 1000}s.`
|
|
112
|
+
);
|
|
113
|
+
await sleep(delayMs);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
throw lastErr;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Truncamento: os dois provedores dizem explicitamente que cortaram a saída no
|
|
120
|
+
// teto de tokens — OpenRouter (formato OpenAI) em `choices[0].finish_reason`,
|
|
121
|
+
// Anthropic em `message.stop_reason`. Ignorar esse campo é o que produzia um
|
|
122
|
+
// documento cortado no meio de uma frase, commitado como se estivesse completo.
|
|
123
|
+
const TRUNCATION_REASONS = new Set(['length', 'max_tokens']);
|
|
124
|
+
|
|
125
|
+
/** Motivo de parada indica saída cortada no teto de tokens? (função PURA) */
|
|
126
|
+
export function isTruncationReason(reason) {
|
|
127
|
+
return TRUNCATION_REASONS.has(reason);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Repetir a MESMA requisição depois de truncar dá o mesmo corte — só sobe o
|
|
131
|
+
// custo. Por isso NÃO é marcado como transitório: o erro sobe, o Action falha
|
|
132
|
+
// visível, destrava a label e comenta na issue o que ajustar.
|
|
133
|
+
export class TruncatedOutputError extends Error {
|
|
134
|
+
constructor({ provider, model, maxTokens, reason, chars }) {
|
|
135
|
+
super(
|
|
136
|
+
`Saída truncada pelo teto de tokens (${provider} · ${model} · max_tokens=${maxTokens} · ` +
|
|
137
|
+
`motivo=${reason}). Foram gerados ~${chars} caracteres antes do corte. ` +
|
|
138
|
+
'Aumente `ai.maxTokens` (ou `ai.maxTokensByAction`) no .spec-wave.json, ou reduza o ' +
|
|
139
|
+
'tamanho da issue de origem. O documento NÃO foi gravado — um documento cortado ' +
|
|
140
|
+
'passaria na validação de seções e valeria menos que nenhum.'
|
|
141
|
+
);
|
|
142
|
+
this.name = 'TruncatedOutputError';
|
|
143
|
+
this.truncated = true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Os parâmetros de sampling foram REMOVIDOS a partir do Claude Opus 4.7 (vale
|
|
148
|
+
// para 4.8 e 5, Sonnet 5, Fable 5 e Mythos 5): enviar temperature/top_p/top_k
|
|
149
|
+
// devolve 400. A OpenRouter hoje normaliza e não quebra, mas o caminho direto
|
|
150
|
+
// da Anthropic quebraria em toda requisição — daí a checagem no modelo, não no
|
|
151
|
+
// provider. Aceita tanto o id primário (`claude-opus-4-8`) quanto o slug da
|
|
152
|
+
// OpenRouter (`anthropic/claude-opus-4.8`).
|
|
153
|
+
const MODELS_WITHOUT_SAMPLING_PARAMS =
|
|
154
|
+
/claude-(?:opus-(?:4[.-]7|4[.-]8|5)|sonnet-5|fable-5|mythos-5)/i;
|
|
155
|
+
|
|
156
|
+
/** O modelo ainda aceita `temperature`? (função PURA) */
|
|
157
|
+
export function supportsTemperature(model) {
|
|
158
|
+
return !MODELS_WITHOUT_SAMPLING_PARAMS.test(model || '');
|
|
159
|
+
}
|
|
160
|
+
|
|
52
161
|
// temperature padrão 0.2 (RFC-002 §5): "Determinism over Creativity". Pode ser
|
|
53
162
|
// sobrescrita por chamada via opts, mas o default cobre spec/plan/decompose.
|
|
54
163
|
//
|
|
@@ -66,10 +175,16 @@ function resolveAi(action) {
|
|
|
66
175
|
export async function generateDocument(systemPrompt, userContent, opts = {}) {
|
|
67
176
|
const ai = resolveAi(opts.action);
|
|
68
177
|
const temperature = opts.temperature ?? 0.2;
|
|
69
|
-
// Modelos de reasoning (ex.: deepseek-r1) consomem tokens
|
|
70
|
-
// resposta, então o teto precisa
|
|
71
|
-
|
|
72
|
-
|
|
178
|
+
// Modelos de reasoning (ex.: deepseek-r1, Opus 4.7+) consomem tokens
|
|
179
|
+
// "pensando" antes da resposta, então o teto precisa cobrir raciocínio +
|
|
180
|
+
// documento — ver DEFAULT_MAX_TOKENS.
|
|
181
|
+
const maxTokens = opts.maxTokens ?? ai.maxTokens;
|
|
182
|
+
const sendTemperature = supportsTemperature(ai.model);
|
|
183
|
+
console.log(
|
|
184
|
+
`Provider de IA: ${ai.provider} · modelo: ${ai.model} · ` +
|
|
185
|
+
`temperature: ${sendTemperature ? temperature : 'n/a (removida neste modelo)'} · ` +
|
|
186
|
+
`max_tokens: ${maxTokens}`
|
|
187
|
+
);
|
|
73
188
|
|
|
74
189
|
// Acumuladores de uso desta invocação (1 ou 2 chamadas, com o retry de lint).
|
|
75
190
|
let inputTokens = 0;
|
|
@@ -77,9 +192,14 @@ export async function generateDocument(systemPrompt, userContent, opts = {}) {
|
|
|
77
192
|
let cost = null; // soma dos custos conhecidos; se todos null → null
|
|
78
193
|
|
|
79
194
|
const generate = async (system) => {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
195
|
+
const callOpts = {
|
|
196
|
+
temperature: sendTemperature ? temperature : undefined,
|
|
197
|
+
maxTokens,
|
|
198
|
+
};
|
|
199
|
+
const { text, usage } = await withRetry(`Geração via ${ai.provider}`, () =>
|
|
200
|
+
ai.provider === 'openrouter'
|
|
201
|
+
? generateWithOpenRouter(system, userContent, ai, callOpts)
|
|
202
|
+
: generateWithAnthropic(system, userContent, ai, callOpts));
|
|
83
203
|
inputTokens += usage.inputTokens;
|
|
84
204
|
outputTokens += usage.outputTokens;
|
|
85
205
|
if (typeof usage.cost === 'number') cost = (cost ?? 0) + usage.cost;
|
|
@@ -179,7 +299,7 @@ export function extractOpenRouterUsage(usage) {
|
|
|
179
299
|
};
|
|
180
300
|
}
|
|
181
301
|
|
|
182
|
-
async function generateWithAnthropic(systemPrompt, userContent, ai, temperature, maxTokens) {
|
|
302
|
+
async function generateWithAnthropic(systemPrompt, userContent, ai, { temperature, maxTokens }) {
|
|
183
303
|
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
184
304
|
if (!apiKey) {
|
|
185
305
|
throw new Error(
|
|
@@ -192,15 +312,56 @@ async function generateWithAnthropic(systemPrompt, userContent, ai, temperature,
|
|
|
192
312
|
const message = await client.messages.create({
|
|
193
313
|
model: ai.model,
|
|
194
314
|
max_tokens: maxTokens,
|
|
195
|
-
|
|
315
|
+
// Omitida nos modelos que removeram sampling (Opus 4.7+, Sonnet 5, Fable 5):
|
|
316
|
+
// enviá-la devolve 400.
|
|
317
|
+
...(temperature === undefined ? {} : { temperature }),
|
|
196
318
|
messages: [{ role: 'user', content: userContent }],
|
|
197
319
|
system: systemPrompt,
|
|
198
320
|
});
|
|
199
321
|
|
|
200
|
-
|
|
322
|
+
// A resposta nem sempre começa com um bloco de texto (recusa, resposta vazia):
|
|
323
|
+
// `content[0].text` cru virava TypeError com mensagem inútil.
|
|
324
|
+
const text = (message.content || [])
|
|
325
|
+
.filter((block) => block.type === 'text')
|
|
326
|
+
.map((block) => block.text)
|
|
327
|
+
.join('');
|
|
328
|
+
|
|
329
|
+
if (message.stop_reason === 'refusal') {
|
|
330
|
+
throw new Error(
|
|
331
|
+
`A Anthropic recusou a requisição (stop_reason=refusal` +
|
|
332
|
+
`${message.stop_details?.category ? `, categoria=${message.stop_details.category}` : ''}). ` +
|
|
333
|
+
'Revise o conteúdo da issue de origem.'
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
if (message.stop_reason === 'model_context_window_exceeded') {
|
|
337
|
+
// Estouro na ENTRADA — subir max_tokens não resolve; o que precisa encolher
|
|
338
|
+
// é a issue/contexto enviado.
|
|
339
|
+
throw new Error(
|
|
340
|
+
`Contexto de entrada excedido (${ai.model}). Reduza o tamanho da issue de origem ` +
|
|
341
|
+
'ou do tech_context antes de repetir.'
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
if (isTruncationReason(message.stop_reason)) {
|
|
345
|
+
throw new TruncatedOutputError({
|
|
346
|
+
provider: 'anthropic',
|
|
347
|
+
model: ai.model,
|
|
348
|
+
maxTokens,
|
|
349
|
+
reason: message.stop_reason,
|
|
350
|
+
chars: text.length,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
if (!text) {
|
|
354
|
+
const err = new Error(
|
|
355
|
+
`A Anthropic retornou resposta sem texto (stop_reason=${message.stop_reason}).`
|
|
356
|
+
);
|
|
357
|
+
err.transient = true;
|
|
358
|
+
throw err;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return { text, usage: extractAnthropicUsage(message.usage) };
|
|
201
362
|
}
|
|
202
363
|
|
|
203
|
-
async function generateWithOpenRouter(systemPrompt, userContent, ai, temperature, maxTokens) {
|
|
364
|
+
async function generateWithOpenRouter(systemPrompt, userContent, ai, { temperature, maxTokens }) {
|
|
204
365
|
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
205
366
|
if (!apiKey) {
|
|
206
367
|
throw new Error(
|
|
@@ -220,7 +381,8 @@ async function generateWithOpenRouter(systemPrompt, userContent, ai, temperature
|
|
|
220
381
|
body: JSON.stringify({
|
|
221
382
|
model: ai.model,
|
|
222
383
|
max_tokens: maxTokens,
|
|
223
|
-
|
|
384
|
+
// Omitida nos modelos que removeram sampling (Opus 4.7+, Sonnet 5, Fable 5).
|
|
385
|
+
...(temperature === undefined ? {} : { temperature }),
|
|
224
386
|
messages: [
|
|
225
387
|
{ role: 'system', content: systemPrompt },
|
|
226
388
|
{ role: 'user', content: userContent },
|
|
@@ -232,13 +394,48 @@ async function generateWithOpenRouter(systemPrompt, userContent, ai, temperature
|
|
|
232
394
|
|
|
233
395
|
if (!res.ok) {
|
|
234
396
|
const body = await res.text();
|
|
235
|
-
|
|
397
|
+
const err = new Error(`OpenRouter API ${res.status}: ${body}`);
|
|
398
|
+
err.status = res.status;
|
|
399
|
+
throw err;
|
|
236
400
|
}
|
|
237
401
|
|
|
238
|
-
|
|
239
|
-
|
|
402
|
+
// Um 200 com corpo vazio ou cortado acontece em gerações longas. `res.json()`
|
|
403
|
+
// cru lançaria "Unexpected end of JSON input" — mensagem que não diz nada a
|
|
404
|
+
// quem está olhando o board. Lê como texto, reporta o que veio e marca como
|
|
405
|
+
// transitória para o retry pegar.
|
|
406
|
+
const raw = await res.text();
|
|
407
|
+
let data;
|
|
408
|
+
try {
|
|
409
|
+
data = JSON.parse(raw);
|
|
410
|
+
} catch {
|
|
411
|
+
const err = new Error(
|
|
412
|
+
`OpenRouter devolveu ${res.status} com corpo inválido (${raw.length} bytes): ` +
|
|
413
|
+
`${raw.slice(0, 200) || '(vazio)'}`
|
|
414
|
+
);
|
|
415
|
+
err.transient = true;
|
|
416
|
+
throw err;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const choice = data?.choices?.[0];
|
|
420
|
+
const content = stripReasoning(choice?.message?.content || '');
|
|
421
|
+
|
|
422
|
+
// finish_reason='length' = cortado no teto. Checado ANTES do conteúdo vazio:
|
|
423
|
+
// um corte durante o raciocínio devolve texto vazio, e "resposta vazia" (que
|
|
424
|
+
// é tratada como transitória) esconderia a causa real por trás de 3 retries.
|
|
425
|
+
if (isTruncationReason(choice?.finish_reason)) {
|
|
426
|
+
throw new TruncatedOutputError({
|
|
427
|
+
provider: 'openrouter',
|
|
428
|
+
model: ai.model,
|
|
429
|
+
maxTokens,
|
|
430
|
+
// native_finish_reason preserva o motivo cru do provedor upstream.
|
|
431
|
+
reason: `${choice.finish_reason}${choice.native_finish_reason ? `/${choice.native_finish_reason}` : ''}`,
|
|
432
|
+
chars: content.length,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
240
435
|
if (!content) {
|
|
241
|
-
|
|
436
|
+
const err = new Error(`OpenRouter retornou resposta vazia: ${JSON.stringify(data)}`);
|
|
437
|
+
err.transient = true;
|
|
438
|
+
throw err;
|
|
242
439
|
}
|
|
243
440
|
return { text: content, usage: extractOpenRouterUsage(data.usage) };
|
|
244
441
|
}
|
package/src/lib/critique.mjs
CHANGED
|
@@ -151,7 +151,11 @@ export async function runCritique({ kind, spec, plan, techContextYaml, stories,
|
|
|
151
151
|
const raw = await generateDocument(buildSystemPrompt(kind), userContent, {
|
|
152
152
|
action: 'critique',
|
|
153
153
|
temperature: 0,
|
|
154
|
-
|
|
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,
|
|
155
159
|
usage,
|
|
156
160
|
});
|
|
157
161
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Rede de segurança contra documento cortado.
|
|
2
|
+
//
|
|
3
|
+
// A defesa principal contra truncamento é a detecção no provedor
|
|
4
|
+
// (claude.mjs → TruncatedOutputError): um documento cortado nunca chega a ser
|
|
5
|
+
// gravado. Esta checagem cobre o que escapa disso — documento editado à mão,
|
|
6
|
+
// commit parcial, ou geração feita por uma versão antiga da CLI.
|
|
7
|
+
//
|
|
8
|
+
// O critério é DELIBERADAMENTE estreito. Só entram sinais que não têm leitura
|
|
9
|
+
// inocente em Markdown: um falso positivo aqui bloqueia o ready de uma Feature
|
|
10
|
+
// legítima, o que é pior que deixar passar um documento suspeito. "Termina sem
|
|
11
|
+
// ponto final" e afins ficaram de fora de propósito — títulos, tabelas e itens
|
|
12
|
+
// de lista terminam assim o tempo todo.
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Procura sinais objetivos de documento incompleto (função PURA).
|
|
16
|
+
*
|
|
17
|
+
* @param {string} content conteúdo do markdown
|
|
18
|
+
* @returns {string[]} descrições dos problemas encontrados (vazio = sem sinais)
|
|
19
|
+
*/
|
|
20
|
+
export function findIncompleteDocSigns(content) {
|
|
21
|
+
const problems = [];
|
|
22
|
+
const text = content || '';
|
|
23
|
+
|
|
24
|
+
if (!text.trim()) {
|
|
25
|
+
return ['o arquivo está vazio'];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 1. Cerca de código aberta. Um ``` sem par significa que o corte aconteceu
|
|
29
|
+
// dentro de um bloco (gherkin, mermaid, yaml) — não há leitura válida.
|
|
30
|
+
const fences = text.split('\n').filter((line) => /^\s*```/.test(line)).length;
|
|
31
|
+
if (fences % 2 !== 0) {
|
|
32
|
+
problems.push(`bloco de código não fechado (${fences} marcações \`\`\`)`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 2. Parênteses/colchetes abertos na última linha com conteúdo. É o formato
|
|
36
|
+
// exato de um corte no meio da frase — ex.: "...(versionCode/S" — e prosa
|
|
37
|
+
// completa não termina assim.
|
|
38
|
+
const lines = text.split('\n').filter((line) => line.trim());
|
|
39
|
+
const lastLine = lines[lines.length - 1] || '';
|
|
40
|
+
for (const [open, close, nome] of [['(', ')', 'parêntese'], ['[', ']', 'colchete']]) {
|
|
41
|
+
const opened = lastLine.split(open).length - 1;
|
|
42
|
+
const closed = lastLine.split(close).length - 1;
|
|
43
|
+
if (opened > closed) {
|
|
44
|
+
problems.push(`${nome} aberto e não fechado na última linha: "${truncate(lastLine)}"`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return problems;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function truncate(line, max = 60) {
|
|
52
|
+
const t = line.trim();
|
|
53
|
+
return t.length > max ? `${t.slice(0, max)}…` : t;
|
|
54
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Detecção do modo "force" (re-executar a etapa ignorando os guards).
|
|
2
|
+
//
|
|
3
|
+
// Os comandos generate-spec/generate-plan/decompose rodam dentro de Actions
|
|
4
|
+
// disparadas por label — a linha de comando do workflow é fixa e não tem como
|
|
5
|
+
// receber uma flag. Por isso o force chega de duas formas equivalentes:
|
|
6
|
+
// • label `spec-wave:force` na issue (fluxo normal, pelo board);
|
|
7
|
+
// • flag `--force` (execução local/manual da CLI).
|
|
8
|
+
// A label é CONSUMIDA pelo run que a leu (ver consumeForceLabel), senão ela
|
|
9
|
+
// ficaria pendurada e forçaria silenciosamente todos os runs seguintes.
|
|
10
|
+
import { LABEL_FORCE } from '../config.mjs';
|
|
11
|
+
import { removeLabel } from '../api/github-rest.mjs';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Diz se a etapa deve ser re-executada ignorando os guards (função PURA).
|
|
15
|
+
*
|
|
16
|
+
* @param {object} [params]
|
|
17
|
+
* @param {Array<string|{name: string}>} [params.labels] labels da issue
|
|
18
|
+
* @param {boolean} [params.flag] valor da flag `--force` da CLI
|
|
19
|
+
* @returns {boolean}
|
|
20
|
+
*/
|
|
21
|
+
export function isForced({ labels = [], flag = false } = {}) {
|
|
22
|
+
if (flag) return true;
|
|
23
|
+
return (labels || [])
|
|
24
|
+
.map(l => (typeof l === 'string' ? l : l?.name))
|
|
25
|
+
.includes(LABEL_FORCE);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Remove a label `spec-wave:force` da issue. Best-effort: nunca lança — deixar
|
|
30
|
+
* de consumir a label é um incômodo, não um motivo para derrubar o comando.
|
|
31
|
+
*/
|
|
32
|
+
export async function consumeForceLabel(token, owner, repo, issueNumber) {
|
|
33
|
+
await removeLabel(token, owner, repo, issueNumber, LABEL_FORCE).catch(() => {});
|
|
34
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
|
3
|
+
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
4
|
+
<plist version="1.0"><dict>
|
|
5
|
+
<key>Label</key><string>dev.specwave.agent</string>
|
|
6
|
+
<key>ProgramArguments</key>
|
|
7
|
+
<array><string>{bin}</string></array>
|
|
8
|
+
<key>RunAtLoad</key><true/>
|
|
9
|
+
<key>KeepAlive</key><true/>
|
|
10
|
+
<key>EnvironmentVariables</key>
|
|
11
|
+
<dict>
|
|
12
|
+
<key>RUST_LOG</key><string>info</string>
|
|
13
|
+
<key>PATH</key><string>{path}</string>
|
|
14
|
+
</dict>
|
|
15
|
+
<key>StandardOutPath</key><string>{home}/Library/Logs/spec-wave-agent.log</string>
|
|
16
|
+
<key>StandardErrorPath</key><string>{home}/Library/Logs/spec-wave-agent.log</string>
|
|
17
|
+
</dict></plist>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=spec-wave dev agent
|
|
3
|
+
After=network-online.target
|
|
4
|
+
Wants=network-online.target
|
|
5
|
+
|
|
6
|
+
[Service]
|
|
7
|
+
ExecStart={bin}
|
|
8
|
+
Restart=on-failure
|
|
9
|
+
RestartSec=10
|
|
10
|
+
Environment=RUST_LOG=info
|
|
11
|
+
Environment=PATH={path}
|
|
12
|
+
# SIGTERM no desligamento => checkpoint + release do lease (takeover imediato)
|
|
13
|
+
KillSignal=SIGTERM
|
|
14
|
+
# Deve ser maior que o tempo do checkpoint (commit + push do WIP)
|
|
15
|
+
TimeoutStopSec=30
|
|
16
|
+
|
|
17
|
+
[Install]
|
|
18
|
+
WantedBy=default.target
|