@spec-wave/cli 0.15.0 → 0.16.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 +1 -0
- package/bin/spec-wave.mjs +44 -5
- 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-graphql.mjs +23 -1
- 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 +22 -72
- 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 +19 -44
- package/src/commands/generate-spec.mjs +18 -46
- 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 +171 -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/flow-run.mjs +145 -0
- package/src/lib/implement-board.mjs +12 -1
- package/src/lib/plugin-skills.mjs +122 -0
- package/src/lib/project-root.mjs +9 -2
- 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 +117 -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 +58 -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 +55 -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 +158 -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
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
|
}
|
package/src/lib/critique.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { generateStructured } from './claude.mjs';
|
|
|
22
22
|
import {
|
|
23
23
|
LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN, DEFAULT_MAX_CRITIQUE_ATTEMPTS, labelNames,
|
|
24
24
|
} from '../config.mjs';
|
|
25
|
+
import { loadPrompt, toolFreeSystemPrompt } from './prompt-loader.mjs';
|
|
25
26
|
|
|
26
27
|
// Severidades aceitas — enum FECHADO. O teste de prefixo /^grave/i sobre texto
|
|
27
28
|
// livre que existia aqui aceitava "gravíssimo" e rebaixava "critical"/"high"
|
|
@@ -41,21 +42,11 @@ const MAX_FINDING_CHARS = 800;
|
|
|
41
42
|
export const CRITIQUE_TOOL_NAME = 'registrar_findings';
|
|
42
43
|
|
|
43
44
|
// Rótulo do artefato auditado, por contexto — usado no cabeçalho do comentário.
|
|
44
|
-
const KIND_LABEL = { plan: 'plan.md', stories: 'decomposition.md' };
|
|
45
|
-
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
const
|
|
49
|
-
plan:
|
|
50
|
-
'Audite o plan.md contra o spec.md, as regras de negócio e o tech_context fornecidos. ' +
|
|
51
|
-
'Procure decisões técnicas que contradizem ou ignoram requisitos da spec e ' +
|
|
52
|
-
'tecnologias/serviços fora do tech_context.',
|
|
53
|
-
stories:
|
|
54
|
-
'Audite a decomposição proposta (documento Markdown com Stories e suas Tasks) contra o ' +
|
|
55
|
-
'spec.md e o plan.md fornecidos. Procure Stories que contradizem, invertem ou ignoram ' +
|
|
56
|
-
'requisitos da spec ou decisões do plan, critérios de aceite incompatíveis com as regras ' +
|
|
57
|
-
'de negócio, e Tasks que não sustentam a Story a que pertencem.',
|
|
58
|
-
};
|
|
45
|
+
const KIND_LABEL = { plan: 'plan.md', stories: 'decomposition.md', bug: 'bug.md' };
|
|
46
|
+
|
|
47
|
+
// Prompt por tipo de auditoria. 'plan' audita o plan.md contra a spec;
|
|
48
|
+
// 'stories' audita a decomposição proposta contra spec + plan.
|
|
49
|
+
const KIND_PROMPT = { plan: 'plan/critique', stories: 'decompose/critique', bug: 'bug/critique' };
|
|
59
50
|
|
|
60
51
|
// A decomposição virou arquivo revisável (decomposition.md): um finding só é
|
|
61
52
|
// acionável se disser ONDE está o problema. Os títulos "## Story N" e
|
|
@@ -67,28 +58,39 @@ const ANCHOR_RULE = `Cada finding DEVE citar, no campo "anchor", o trecho audita
|
|
|
67
58
|
- "geral" quando o problema for da decomposição como um todo (ex.: requisito da spec que nenhuma Story cobre).
|
|
68
59
|
Use EXATAMENTE os números que aparecem nos títulos "## Story N — ..." e "### Task N.M — ..." do documento.`;
|
|
69
60
|
|
|
70
|
-
|
|
71
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Monta o system prompt da crítica: corpo editável + contrato de máquina.
|
|
63
|
+
*
|
|
64
|
+
* A divisão não é estética. O CORPO (`model-prompt.critique.md` da skill do comando) é a redação
|
|
65
|
+
* — foco da auditoria, o que conta como achado, a barra de rigor — e o time
|
|
66
|
+
* pode sobrescrevê-lo em `.spec-wave/prompts/` para apertar ou afrouxar o
|
|
67
|
+
* critério. O CONTRATO abaixo é acoplado a `critiqueJsonSchema()` e a
|
|
68
|
+
* `validateCritiquePayload()`: o enum de `severity`, a âncora `Story N` /
|
|
69
|
+
* `Task N.M` e a obrigação de chamar a tool. Ele é anexado por ÚLTIMO
|
|
70
|
+
* justamente para que um override de projeto não consiga anulá-lo — um payload
|
|
71
|
+
* fora do contrato falha alto, e não vale deixar isso na mão do texto.
|
|
72
|
+
*
|
|
73
|
+
* @param {'plan'|'stories'} kind
|
|
74
|
+
* @param {string} [cwd] raiz do repositório (resolve o override do projeto)
|
|
75
|
+
*/
|
|
76
|
+
function buildSystemPrompt(kind, cwd) {
|
|
77
|
+
const prompt = loadPrompt(KIND_PROMPT[kind] || KIND_PROMPT.plan, ...(cwd ? [{ cwd }] : []));
|
|
72
78
|
const anchor = kind === 'stories' ? `\n\n${ANCHOR_RULE}` : '';
|
|
73
|
-
return `Você é um revisor técnico CÉTICO e adversarial. Seu papel é encontrar problemas, não elogiar.
|
|
74
79
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
Liste:
|
|
78
|
-
- contradições diretas entre os documentos;
|
|
79
|
-
- inversões de requisito (ex.: consentimento→persistência invertidos: a spec exige consentimento ANTES de persistir e o documento persiste antes de pedir consentimento);
|
|
80
|
-
- violações de restrições explícitas (ex.: minimização de dados LGPD, limites de retenção, campos proibidos);
|
|
81
|
-
- itens que contradizem ou ignoram a spec.
|
|
80
|
+
const contract = `## Contrato de saída
|
|
82
81
|
|
|
83
82
|
Classifique cada finding no campo "severity", usando EXATAMENTE um destes dois valores:
|
|
84
83
|
- "grave": contradiz um requisito ou regra explícita — causaria implementação errada;
|
|
85
84
|
- "menor": inconsistência, omissão ou ambiguidade que merece atenção mas não inverte requisito.
|
|
86
85
|
|
|
87
|
-
NÃO invente problemas: se os documentos estiverem consistentes, retorne a lista vazia.
|
|
88
86
|
Escreva os findings em português (pt-BR), em uma frase objetiva cada.${anchor}
|
|
89
87
|
|
|
90
88
|
Registre o resultado chamando a ferramenta \`${CRITIQUE_TOOL_NAME}\`. Nunca responda em texto livre.
|
|
91
89
|
Se os documentos estiverem consistentes, chame-a com "findings": [].`;
|
|
90
|
+
|
|
91
|
+
// Sem tool loop de leitura aqui: `generateStructured` faz UMA chamada, com a
|
|
92
|
+
// tool de saída estruturada como única ferramenta.
|
|
93
|
+
return toolFreeSystemPrompt(prompt, contract);
|
|
92
94
|
}
|
|
93
95
|
|
|
94
96
|
function critiqueJsonSchema(kind) {
|
|
@@ -338,6 +340,11 @@ const KIND_TRAILER = {
|
|
|
338
340
|
`\`decomposition.md\` (as âncoras acima apontam para ele), remova a label ` +
|
|
339
341
|
`\`${LABEL_CRITIQUE_FAILED}\` e reaplique \`spec-wave:decompose\` para uma nova crítica.`
|
|
340
342
|
: '_Findings menores não bloqueiam a decomposição._'),
|
|
343
|
+
bug: (graves) => (graves
|
|
344
|
+
? `⛔ Há findings **graves**: a label \`${LABEL_CRITIQUE_FAILED}\` bloqueia a triagem ` +
|
|
345
|
+
'até ser removida. Um bug com causa raiz errada produz correção errada — corrija o ' +
|
|
346
|
+
'`bug.md` e reaplique `spec-wave:bug`.'
|
|
347
|
+
: '_Findings menores não bloqueiam a triagem._'),
|
|
341
348
|
};
|
|
342
349
|
|
|
343
350
|
/**
|
|
@@ -406,11 +413,13 @@ export function renderCritiqueMarkdown({
|
|
|
406
413
|
* chamador decidir entre seguir com aviso e abortar.
|
|
407
414
|
*
|
|
408
415
|
* @param {object} params
|
|
409
|
-
* @param {'plan'|'stories'} params.kind o que está sendo auditado
|
|
416
|
+
* @param {'plan'|'stories'|'bug'} params.kind o que está sendo auditado
|
|
410
417
|
* @param {string} [params.spec] conteúdo do spec.md
|
|
411
418
|
* @param {string} [params.plan] conteúdo do plan.md
|
|
412
419
|
* @param {string} [params.techContextYaml] tech_context serializado em YAML
|
|
413
420
|
* @param {string} [params.decomposition] conteúdo do decomposition.md
|
|
421
|
+
* @param {string} [params.bugDoc] conteúdo do bug.md
|
|
422
|
+
* @param {string} [params.bugReport] relato original (corpo da issue + comentários)
|
|
414
423
|
* @param {number} [params.attempt] tentativa em curso (cabeçalho e marcador)
|
|
415
424
|
* @param {number} [params.maxAttempts]
|
|
416
425
|
* @param {string} [params.model] modelo imposto (escalada); undefined = cadeia normal
|
|
@@ -419,9 +428,9 @@ export function renderCritiqueMarkdown({
|
|
|
419
428
|
* @returns {Promise<{grave, findings, markdown, attempt, model}>}
|
|
420
429
|
*/
|
|
421
430
|
export async function runCritique({
|
|
422
|
-
kind, spec, plan, techContextYaml, decomposition,
|
|
431
|
+
kind, spec, plan, techContextYaml, decomposition, bugDoc, bugReport,
|
|
423
432
|
attempt = 1, maxAttempts = DEFAULT_MAX_CRITIQUE_ATTEMPTS,
|
|
424
|
-
model, labels = [], usage,
|
|
433
|
+
model, labels = [], usage, cwd,
|
|
425
434
|
} = {}) {
|
|
426
435
|
const sections = [];
|
|
427
436
|
if (spec) sections.push(`## spec.md\n\n${spec}`);
|
|
@@ -434,9 +443,13 @@ export async function runCritique({
|
|
|
434
443
|
`## Decomposição proposta (decomposition.md)\n\n\`\`\`\`markdown\n${decomposition}\n\`\`\`\``
|
|
435
444
|
);
|
|
436
445
|
}
|
|
446
|
+
// O relato é a fonte contra a qual o bug.md é auditado: a causa raiz proposta
|
|
447
|
+
// tem que explicar OS SINTOMAS RELATADOS, não sintomas plausíveis quaisquer.
|
|
448
|
+
if (bugReport) sections.push(`## Relato original (issue e comentários)\n\n${bugReport}`);
|
|
449
|
+
if (bugDoc) sections.push(`## bug.md\n\n${bugDoc}`);
|
|
437
450
|
const userContent = sections.join('\n\n') || '(nenhum documento fornecido)';
|
|
438
451
|
|
|
439
|
-
const report = await generateStructured(buildSystemPrompt(kind), userContent, {
|
|
452
|
+
const report = await generateStructured(buildSystemPrompt(kind, cwd), userContent, {
|
|
440
453
|
action: 'critique',
|
|
441
454
|
temperature: 0,
|
|
442
455
|
schema: {
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Modo de execução do fluxo: GitHub Actions ou sessão local.
|
|
2
|
+
//
|
|
3
|
+
// `generate-spec`, `generate-plan` e `decompose` nasceram como comandos de
|
|
4
|
+
// Action e exigiam `GITHUB_REPOSITORY`, recusando qualquer execução fora do
|
|
5
|
+
// runner. Mas eles são só comandos — o que os prendia ao CI era a resolução de
|
|
6
|
+
// owner/repo, não o trabalho em si. Agora o MESMO comando roda nos dois lugares:
|
|
7
|
+
// dentro do Action, disparado por label, ou na sua sessão do agente.
|
|
8
|
+
//
|
|
9
|
+
// O modo é detectado pelo ambiente (`GITHUB_ACTIONS`), não por flag: um
|
|
10
|
+
// comando com dois nomes para a mesma coisa envelhece mal.
|
|
11
|
+
//
|
|
12
|
+
// **O comportamento é deliberadamente IDÊNTICO nos dois modos** — gera, commita,
|
|
13
|
+
// dá pull --rebase, faz push, comenta na issue e avança a Etapa. O board é a
|
|
14
|
+
// fonte de verdade do RFC-001 independentemente de onde a geração rodou; um modo
|
|
15
|
+
// local que não sincronizasse o board deixaria o próximo passo do fluxo cego.
|
|
16
|
+
//
|
|
17
|
+
// Duas diferenças existem, e as duas são de SEGURANÇA, não de resultado:
|
|
18
|
+
//
|
|
19
|
+
// 1. IDENTIDADE DO GIT. O Action roda `git config user.email "spec-wave[bot]"`
|
|
20
|
+
// sem `--global`, o que grava em `.git/config`. Num runner descartável isso
|
|
21
|
+
// é inócuo; no seu clone, mudaria o autor de TODOS os seus commits futuros
|
|
22
|
+
// naquele repositório. Localmente a sua identidade é preservada.
|
|
23
|
+
// 2. FALHA DE PUSH. No Action, não conseguir publicar é falha do job. Local, o
|
|
24
|
+
// arquivo já está gerado e commitado — perder isso porque o remoto andou
|
|
25
|
+
// seria pior que avisar e deixar você resolver o push.
|
|
26
|
+
|
|
27
|
+
import { execSync } from 'node:child_process';
|
|
28
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import { resolveRepoContext } from './project-root.mjs';
|
|
31
|
+
import { CONFIG_FILE } from '../config.mjs';
|
|
32
|
+
|
|
33
|
+
/** Rodando dentro do GitHub Actions? (função PURA) */
|
|
34
|
+
export function isActionsRun(env = process.env) {
|
|
35
|
+
return env.GITHUB_ACTIONS === 'true';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 'actions' | 'local' (função PURA) */
|
|
39
|
+
export function executionMode(env = process.env) {
|
|
40
|
+
return isActionsRun(env) ? 'actions' : 'local';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve owner/repo + modo, ou lança com a instrução certa para cada contexto.
|
|
45
|
+
*
|
|
46
|
+
* Nos Actions o `GITHUB_REPOSITORY` vem do runner; local, o `.spec-wave.json`
|
|
47
|
+
* gravado pelo `init` é a fonte. `resolveRepoContext` já cobre os dois.
|
|
48
|
+
*
|
|
49
|
+
* @param {object} [opts]
|
|
50
|
+
* @param {string} [opts.cwd]
|
|
51
|
+
* @param {string} [opts.command] nome do comando, para a mensagem de erro
|
|
52
|
+
* @param {object} [opts.env]
|
|
53
|
+
* @returns {{owner: string, repo: string, root: string|null, config: object|null, mode: 'actions'|'local'}}
|
|
54
|
+
*/
|
|
55
|
+
export function resolveFlowContext({ cwd = process.cwd(), command = 'este comando', env = process.env } = {}) {
|
|
56
|
+
// Repassa o `env` recebido: sem isso o modo local fica intestável dentro do
|
|
57
|
+
// GitHub Actions, que define GITHUB_REPOSITORY em toda execução.
|
|
58
|
+
const { owner, repo, root, config } = resolveRepoContext(cwd, env);
|
|
59
|
+
const mode = executionMode(env);
|
|
60
|
+
|
|
61
|
+
if (!owner || !repo) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
'Não foi possível determinar owner/repo.\n' +
|
|
64
|
+
(mode === 'actions'
|
|
65
|
+
? 'No GitHub Actions, o runner define GITHUB_REPOSITORY — verifique o workflow.'
|
|
66
|
+
: `Rode dentro de um repositório com ${CONFIG_FILE} (\`spec-wave init\`), ` +
|
|
67
|
+
`ou defina GITHUB_REPOSITORY=owner/repo:\n` +
|
|
68
|
+
` GITHUB_REPOSITORY=owner/repo spec-wave ${command} --issue-number 1`)
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return { owner, repo, root, config, mode };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Grava um arquivo gerado e o publica: commit + pull --rebase + push.
|
|
76
|
+
*
|
|
77
|
+
* Era o mesmo bloco copiado em `generate-spec`, `generate-plan` e `decompose`,
|
|
78
|
+
* com a identidade do bot embutida. Centralizado aqui para que a diferença
|
|
79
|
+
* entre os modos exista num lugar só.
|
|
80
|
+
*
|
|
81
|
+
* O commit é escopado ao caminho (`git commit -- <arquivo>`): sem isso, qualquer
|
|
82
|
+
* coisa que você já tivesse no index entraria junto no commit do spec-wave —
|
|
83
|
+
* irrelevante num runner limpo, nada irrelevante no seu clone.
|
|
84
|
+
*
|
|
85
|
+
* @param {object} params
|
|
86
|
+
* @param {string} params.filePath caminho absoluto do arquivo
|
|
87
|
+
* @param {string} params.content
|
|
88
|
+
* @param {string} params.message mensagem de commit
|
|
89
|
+
* @param {'actions'|'local'} params.mode
|
|
90
|
+
* @returns {{committed: boolean, pushed: boolean, warning: string|null}}
|
|
91
|
+
*/
|
|
92
|
+
export function commitGenerated({ filePath, content, message, mode }) {
|
|
93
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
94
|
+
writeFileSync(filePath, content, 'utf-8');
|
|
95
|
+
|
|
96
|
+
const git = (cmd, opts = {}) => execSync(cmd, { stdio: 'inherit', ...opts });
|
|
97
|
+
const gitQuiet = (cmd) => execSync(cmd, { stdio: 'pipe' }).toString().trim();
|
|
98
|
+
|
|
99
|
+
if (mode === 'actions') {
|
|
100
|
+
// Runner descartável: identidade do bot é o que se quer no histórico.
|
|
101
|
+
git('git config user.email "spec-wave[bot]@github.com"');
|
|
102
|
+
git('git config user.name "spec-wave[bot]"');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
git(`git add "${filePath}"`);
|
|
106
|
+
|
|
107
|
+
// Nada mudou (regerar conteúdo idêntico) → `git commit` sairia 1 e derrubaria
|
|
108
|
+
// o comando depois de o trabalho estar feito.
|
|
109
|
+
let hasChanges = true;
|
|
110
|
+
try {
|
|
111
|
+
execSync(`git diff --cached --quiet -- "${filePath}"`, { stdio: 'pipe' });
|
|
112
|
+
hasChanges = false;
|
|
113
|
+
} catch {
|
|
114
|
+
hasChanges = true;
|
|
115
|
+
}
|
|
116
|
+
if (!hasChanges) {
|
|
117
|
+
return { committed: false, pushed: false, warning: 'conteúdo idêntico ao já versionado — nada a commitar' };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
git(`git commit -m "${message}" -- "${filePath}"`);
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
git('git pull --rebase');
|
|
124
|
+
git('git push');
|
|
125
|
+
return { committed: true, pushed: true, warning: null };
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (mode === 'actions') throw err;
|
|
128
|
+
// Local: o arquivo está gerado e commitado. Derrubar o comando aqui
|
|
129
|
+
// esconderia esse fato atrás de um erro de rede/divergência.
|
|
130
|
+
const branch = (() => {
|
|
131
|
+
try {
|
|
132
|
+
return gitQuiet('git rev-parse --abbrev-ref HEAD');
|
|
133
|
+
} catch {
|
|
134
|
+
return 'seu branch';
|
|
135
|
+
}
|
|
136
|
+
})();
|
|
137
|
+
return {
|
|
138
|
+
committed: true,
|
|
139
|
+
pushed: false,
|
|
140
|
+
warning:
|
|
141
|
+
`commit feito em ${branch}, mas o push falhou (${err.message.split('\n')[0]}). ` +
|
|
142
|
+
'O arquivo está salvo e versionado — publique quando resolver.',
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|