@spec-wave/cli 0.21.0 → 0.24.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 +25 -5
- package/bin/spec-wave.mjs +2 -2
- package/package.json +1 -1
- package/src/agent/anthropic-agent.mjs +43 -20
- package/src/agent/errors.mjs +59 -13
- package/src/agent/index.mjs +104 -38
- package/src/agent/openrouter-agent.mjs +5 -1
- package/src/api/github-graphql.mjs +63 -0
- package/src/api/github-rest.mjs +9 -2
- package/src/commands/decompose.mjs +82 -5
- package/src/commands/doctor.mjs +38 -12
- package/src/commands/init.mjs +5 -0
- package/src/commands/move.mjs +52 -0
- package/src/commands/order.mjs +200 -7
- package/src/commands/validate.mjs +39 -18
- package/src/config.mjs +45 -8
- package/src/lib/board.mjs +34 -1
- package/src/lib/bug-doc.mjs +71 -0
- package/src/lib/claude.mjs +36 -9
- package/src/lib/decomposition-doc.mjs +66 -14
- package/src/lib/dependencies.mjs +14 -4
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/skills/decompose/SKILL.md +3 -3
- package/src/plugin/skills/doctor/SKILL.md +1 -1
- package/src/plugin/skills/order/SKILL.md +8 -4
- package/src/plugin/skills/ready/SKILL.md +1 -1
- package/src/plugin/skills/setup/SKILL.md +1 -1
- package/src/templates/skill/SKILL.md +5 -5
- package/src/templates/workflows/critique.yml +1 -0
- package/src/templates/workflows/decompose.yml +1 -0
- package/src/templates/workflows/generate-bug.yml +1 -0
- package/src/templates/workflows/generate-plan.yml +1 -0
- package/src/templates/workflows/generate-spec.yml +1 -0
package/README.md
CHANGED
|
@@ -108,7 +108,7 @@ Arquivo em `.github/config/tech_context.yml` que descreve a stack tecnológica d
|
|
|
108
108
|
```bash
|
|
109
109
|
gh auth refresh --scopes project,repo,workflow
|
|
110
110
|
```
|
|
111
|
-
- Secret no repositório: `ANTHROPIC_API_KEY` ou `OPENROUTER_API_KEY` (Settings → Secrets → Actions)
|
|
111
|
+
- Secret no repositório, conforme o provider: `ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN` (assinatura Claude Pro/Max) ou `OPENROUTER_API_KEY` (Settings → Secrets → Actions)
|
|
112
112
|
- Para repositórios em organizações: criar PAT com escopo `project` e adicionar como secret `GH_PROJECT_TOKEN`
|
|
113
113
|
|
|
114
114
|
---
|
|
@@ -191,10 +191,10 @@ npx @spec-wave/cli@latest init --repo acme/loja --project-title "Loja — Spec W
|
|
|
191
191
|
O `init` cria:
|
|
192
192
|
- GitHub Project v2 com 12 colunas Kanban e campos personalizados (Work Item Type, Priority, Story Points, Area)
|
|
193
193
|
- 20+ labels de tipo, prioridade e gatilho
|
|
194
|
-
-
|
|
194
|
+
- 8 GitHub Actions workflows em `.github/workflows/`
|
|
195
195
|
- `.spec-wave.json` com os IDs do Project
|
|
196
196
|
|
|
197
|
-
Adicionar o secret de IA no GitHub: **Settings → Secrets → Actions
|
|
197
|
+
Adicionar o secret de IA no GitHub: **Settings → Secrets → Actions** — `ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN` ou `OPENROUTER_API_KEY`, conforme o provider escolhido no `init`.
|
|
198
198
|
|
|
199
199
|
---
|
|
200
200
|
|
|
@@ -408,14 +408,34 @@ Isso atualiza apenas os arquivos de workflow sem recriar o Project ou as labels.
|
|
|
408
408
|
|
|
409
409
|
| Provider | Secret | Modelo padrão |
|
|
410
410
|
|----------|--------|---------------|
|
|
411
|
-
|
|
|
412
|
-
|
|
|
411
|
+
| `anthropic` | `ANTHROPIC_API_KEY` | `claude-sonnet-4-6` |
|
|
412
|
+
| `claude-oauth` | `CLAUDE_CODE_OAUTH_TOKEN` | `claude-sonnet-4-6` |
|
|
413
|
+
| `openrouter` | `OPENROUTER_API_KEY` | `anthropic/claude-3.7-sonnet` |
|
|
413
414
|
|
|
414
415
|
Configurar no `init`:
|
|
415
416
|
```bash
|
|
416
417
|
npx @spec-wave/cli@latest init --repo owner/repo --provider openrouter --model anthropic/claude-3.7-sonnet
|
|
417
418
|
```
|
|
418
419
|
|
|
420
|
+
### `claude-oauth` — rodar tudo pela assinatura Claude Pro/Max
|
|
421
|
+
|
|
422
|
+
Mesmo motor do provider `anthropic`, credencial diferente: em vez de uma chave
|
|
423
|
+
de API cobrada por token, o token da sua assinatura. Os cinco workflows de IA
|
|
424
|
+
(spec, plan, bug, decompose, critique) rodam assim, inclusive nos GitHub Actions
|
|
425
|
+
— o binário do Claude Code vem embarcado na CLI, então o runner não precisa de
|
|
426
|
+
nada além do `setup-node` que os workflows já fazem.
|
|
427
|
+
|
|
428
|
+
```bash
|
|
429
|
+
claude setup-token # gere o token (escopo só de inferência)
|
|
430
|
+
# cole o valor em Settings → Secrets → Actions → CLAUDE_CODE_OAUTH_TOKEN
|
|
431
|
+
|
|
432
|
+
npx @spec-wave/cli@latest init --repo owner/repo --provider claude-oauth
|
|
433
|
+
# ou, num repo já configurado, troque "provider" no .spec-wave.json e rode `update`
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
O consumo passa a sair do limite do seu plano — sob rate limit da assinatura o
|
|
437
|
+
job falha por indisponibilidade, não por erro de configuração.
|
|
438
|
+
|
|
419
439
|
---
|
|
420
440
|
|
|
421
441
|
## Licença
|
package/bin/spec-wave.mjs
CHANGED
|
@@ -263,8 +263,8 @@ program
|
|
|
263
263
|
|
|
264
264
|
program
|
|
265
265
|
.command('order')
|
|
266
|
-
.description('Ordena as Stories
|
|
267
|
-
.argument('
|
|
266
|
+
.description('Ordena as Stories pelas dependências (topológica). Sem argumento, o mapa de todas as Features com trabalho')
|
|
267
|
+
.argument('[feature]', 'Número da issue da Feature, ex.: 12 ou #12. Omitido: todas as Features abertas fora de 🎉 Done')
|
|
268
268
|
.action(async (feature) => {
|
|
269
269
|
const { order } = await import('../src/commands/order.mjs');
|
|
270
270
|
await order({ feature }).catch(err => { console.error(err.message); process.exit(1); });
|
package/package.json
CHANGED
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
// e da execução das tools. Por isso não há dependência direta de
|
|
7
7
|
// `@anthropic-ai/sdk` aqui — não reintroduza uma.
|
|
8
8
|
//
|
|
9
|
-
// Consequência operacional que vale lembrar:
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
9
|
+
// Consequência operacional que vale lembrar: o binário vem EMBARCADO no SDK
|
|
10
|
+
// (dependência opcional por plataforma), então `npm install -g @spec-wave/cli`
|
|
11
|
+
// basta — inclusive no runner do GitHub Actions, que só tem `setup-node`. Quem
|
|
12
|
+
// confere isso antes da primeira chamada é `assertBackendRunnable` em index.mjs.
|
|
13
13
|
//
|
|
14
14
|
// Tudo que o processo pai aprende chega por dois canais, e os dois viram
|
|
15
15
|
// observações do Langfuse manualmente:
|
|
@@ -25,12 +25,24 @@
|
|
|
25
25
|
import path from 'node:path';
|
|
26
26
|
import { withTrace, startObservation, updateSpan, endObservation } from './tracing.mjs';
|
|
27
27
|
import { ENV_FILE_PATTERN } from './tools.mjs';
|
|
28
|
-
import { summarizeToolCalls } from './errors.mjs';
|
|
28
|
+
import { summarizeToolCalls, toolSignature } from './errors.mjs';
|
|
29
29
|
import { TruncatedOutputError } from './errors.mjs';
|
|
30
30
|
|
|
31
31
|
const DEFAULT_TOOLS = ['Read', 'Glob', 'Grep'];
|
|
32
32
|
const MAX_TURNS = 25;
|
|
33
33
|
|
|
34
|
+
// Variáveis de credencial cujo valor vazio deve ser tratado como AUSENTE.
|
|
35
|
+
const CREDENTIAL_VARS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'];
|
|
36
|
+
|
|
37
|
+
/** Cópia do ambiente sem credenciais de valor vazio (função PURA). */
|
|
38
|
+
export function withoutBlankCredentials(env) {
|
|
39
|
+
const copy = { ...env };
|
|
40
|
+
for (const key of CREDENTIAL_VARS) {
|
|
41
|
+
if (copy[key] !== undefined && String(copy[key]).trim() === '') delete copy[key];
|
|
42
|
+
}
|
|
43
|
+
return copy;
|
|
44
|
+
}
|
|
45
|
+
|
|
34
46
|
/** Base de comparação da allowlist: absoluto resolvido, minúsculo no win32. */
|
|
35
47
|
function normalizeWritePath(p) {
|
|
36
48
|
const resolved = path.resolve(p);
|
|
@@ -45,22 +57,24 @@ function normalizeWritePath(p) {
|
|
|
45
57
|
* @returns {Promise<import('./run-types.mjs').AgentRunResult>}
|
|
46
58
|
*/
|
|
47
59
|
export async function runAnthropicAgent(prompt, options) {
|
|
48
|
-
if (options.responseSchema) {
|
|
49
|
-
// O subprocesso não expõe tool_choice forçado, então não há como garantir
|
|
50
|
-
// a tool call única que a saída estruturada exige. Falhar aqui é melhor do
|
|
51
|
-
// que devolver texto livre que o chamador tentaria parsear "na tolerância"
|
|
52
|
-
// — foi exatamente esse caminho que rebaixava um finding grave a menor.
|
|
53
|
-
throw new Error(
|
|
54
|
-
'Saída estruturada não é suportada no backend anthropic (o subprocesso do ' +
|
|
55
|
-
'Claude Code não expõe tool_choice forçado). Use o provider openrouter para ' +
|
|
56
|
-
'ações com schema — hoje, a crítica adversarial.',
|
|
57
|
-
);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
60
|
const { query } = await import('@anthropic-ai/claude-agent-sdk');
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
// Saída estruturada: `outputFormat` faz o CLI garantir um payload conforme o
|
|
63
|
+
// schema (ele mesmo repete o turno enquanto não bater) e devolvê-lo em
|
|
64
|
+
// `structured_output` no result. Não existe equivalente ao `strict` do
|
|
65
|
+
// OpenRouter aqui — a garantia é o loop de retry do CLI, não o tipo do
|
|
66
|
+
// endpoint; por isso `responseSchema.strict` é ignorado neste backend.
|
|
67
|
+
const schema = options.responseSchema ?? null;
|
|
68
|
+
// Com schema o modelo não explora nada: a resposta é UM payload. Misturar as
|
|
69
|
+
// duas coisas daria a saída de escape de "chamar Read de novo" em vez de
|
|
70
|
+
// fechar o contrato — mesma regra do backend openrouter.
|
|
71
|
+
//
|
|
72
|
+
// O que NÃO se copia de lá é o `maxTurns: 1`: aqui o turno extra é do próprio
|
|
73
|
+
// CLI refazendo a resposta até casar com o schema (um run que deu certo
|
|
74
|
+
// reportou 2 turnos). Apertar o teto trocaria esse retry por error_max_turns.
|
|
75
|
+
const tools = schema ? [] : (options.tools ?? DEFAULT_TOOLS);
|
|
76
|
+
// `^()$` casaria com nome vazio; sem tools, o matcher não pode casar nada.
|
|
77
|
+
const toolMatcher = tools.length > 0 ? `^(${tools.join('|')})$` : '(?!)';
|
|
64
78
|
const allowedWritePaths = options.allowedWritePaths?.map(normalizeWritePath) ?? null;
|
|
65
79
|
const runResult = {
|
|
66
80
|
resultSubtype: null,
|
|
@@ -72,6 +86,7 @@ export async function runAnthropicAgent(prompt, options) {
|
|
|
72
86
|
// Simétrico ao backend openrouter: sem isto, um run que estoura o teto de
|
|
73
87
|
// turnos não deixa registro do que o modelo esteve fazendo.
|
|
74
88
|
toolCalls: [],
|
|
89
|
+
toolSignatures: [],
|
|
75
90
|
};
|
|
76
91
|
const quiet = options.quiet !== false;
|
|
77
92
|
|
|
@@ -97,6 +112,7 @@ export async function runAnthropicAgent(prompt, options) {
|
|
|
97
112
|
const queryOptions = {
|
|
98
113
|
model: options.model,
|
|
99
114
|
maxTurns: options.maxTurns ?? MAX_TURNS,
|
|
115
|
+
...(schema ? { outputFormat: { type: 'json_schema', schema: schema.jsonSchema } } : {}),
|
|
100
116
|
// `tools` restringe o que o modelo sequer VÊ; `allowedTools` é só a
|
|
101
117
|
// lista de auto-aprovação sobre essa superfície. Sem `tools`, as ~31
|
|
102
118
|
// tools do Claude Code aparecem e são apenas aprovadas/negadas por
|
|
@@ -122,7 +138,12 @@ export async function runAnthropicAgent(prompt, options) {
|
|
|
122
138
|
],
|
|
123
139
|
}
|
|
124
140
|
: {}),
|
|
125
|
-
env
|
|
141
|
+
// `env` SUBSTITUI o ambiente do subprocesso, então o spread é obrigatório
|
|
142
|
+
// (PATH, HOME). O saneamento existe porque `${{ secrets.X }}` de um
|
|
143
|
+
// secret inexistente injeta string VAZIA: um CLAUDE_CODE_OAUTH_TOKEN=""
|
|
144
|
+
// chegando ao CLI é ambiguidade gratuita entre "não configurado" e
|
|
145
|
+
// "configurado errado".
|
|
146
|
+
env: withoutBlankCredentials(process.env),
|
|
126
147
|
// Desliga o carregamento de settings do filesystem (~/.claude,
|
|
127
148
|
// .claude/settings.json) para o subprocesso não herdar hooks ou
|
|
128
149
|
// permissões de fora deste `queryOptions`.
|
|
@@ -159,6 +180,7 @@ export async function runAnthropicAgent(prompt, options) {
|
|
|
159
180
|
}
|
|
160
181
|
|
|
161
182
|
runResult.toolCalls.push(input.tool_name);
|
|
183
|
+
runResult.toolSignatures.push(toolSignature(input.tool_name, input.tool_input));
|
|
162
184
|
if (options.verbose) {
|
|
163
185
|
console.log(`\n[Tool] ${input.tool_name}(${JSON.stringify(input.tool_input)})`);
|
|
164
186
|
}
|
|
@@ -288,6 +310,7 @@ export async function runAnthropicAgent(prompt, options) {
|
|
|
288
310
|
if (message.subtype === 'success') {
|
|
289
311
|
output = message.result;
|
|
290
312
|
runResult.outputText = message.result;
|
|
313
|
+
if (schema) runResult.structured = message.structured_output ?? null;
|
|
291
314
|
if (!quiet && !printedAnyText && message.result.trim() !== '') {
|
|
292
315
|
console.log(`\nClaude: ${message.result}`);
|
|
293
316
|
}
|
package/src/agent/errors.mjs
CHANGED
|
@@ -45,34 +45,80 @@ export class TruncatedOutputError extends Error {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Assinatura de uma tool call: nome + ALVO (função PURA).
|
|
50
|
+
*
|
|
51
|
+
* `Read×59` não distingue "leu 59 arquivos diferentes" de "leu o mesmo arquivo
|
|
52
|
+
* 59 vezes", e essas duas situações pedem remédios opostos. O alvo é o que
|
|
53
|
+
* separa as duas, e é o único dado do input que entra aqui — nada de conteúdo.
|
|
54
|
+
*/
|
|
55
|
+
export function toolSignature(name, input) {
|
|
56
|
+
const alvo = input && typeof input === 'object'
|
|
57
|
+
? input.file_path ?? input.path ?? input.pattern ?? input.query ?? input.command ?? null
|
|
58
|
+
: null;
|
|
59
|
+
return alvo ? `${name}:${String(alvo)}` : String(name);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* O modelo estava EXPLORANDO (e não preso num loop)? (função PURA)
|
|
64
|
+
*
|
|
65
|
+
* Explorar = chamadas majoritariamente distintas: leu muitos arquivos, varreu
|
|
66
|
+
* muitos padrões e o orçamento acabou antes de escrever. Isso VARIA entre
|
|
67
|
+
* execuções — repetir a mesma requisição costuma passar. Loop degenerado é o
|
|
68
|
+
* contrário: a mesma chamada repetida, que repetir só encarece.
|
|
69
|
+
*
|
|
70
|
+
* Amostra pequena não sustenta nenhuma das duas conclusões, e o default é o
|
|
71
|
+
* conservador (não repetir).
|
|
72
|
+
*/
|
|
73
|
+
export function looksLikeExploration(signatures = []) {
|
|
74
|
+
if (!Array.isArray(signatures) || signatures.length < 4) return false;
|
|
75
|
+
const distintas = new Set(signatures).size;
|
|
76
|
+
return distintas / signatures.length >= 0.5;
|
|
77
|
+
}
|
|
78
|
+
|
|
48
79
|
// Teto de turnos esgotado: o modelo respondeu com tool calls em TODOS os turnos
|
|
49
|
-
// e nunca produziu o documento.
|
|
50
|
-
//
|
|
80
|
+
// e nunca produziu o documento.
|
|
81
|
+
//
|
|
82
|
+
// NÃO é uma falha só, são duas, e a distinção é o que decide se repetir vale:
|
|
83
|
+
//
|
|
84
|
+
// • LOOP DEGENERADO (a mesma chamada de novo e de novo) é determinístico —
|
|
85
|
+
// repetir custou 55 minutos de Action num caso real: três tentativas de 25
|
|
86
|
+
// turnos (4min → 19min → 32min) para a mesma falha, que devia ter aparecido
|
|
87
|
+
// na primeira. Aqui não se repete: troca-se o modelo ou sobe-se o teto.
|
|
51
88
|
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
89
|
+
// • EXPLORAÇÃO (chamadas distintas: Read×59 em 59 arquivos, Glob×20) é
|
|
90
|
+
// VARIÂNCIA, não limite estrutural — o orçamento acabou antes de escrever.
|
|
91
|
+
// Tratar isso como determinístico mandava trocar de modelo por nada: em
|
|
92
|
+
// quatro ocorrências reais, três reaplicações da MESMA label no MESMO modelo
|
|
93
|
+
// passaram na segunda tentativa, uma delas depois de um run de 20min46s que
|
|
94
|
+
// uma repetição de ~2min teria evitado.
|
|
55
95
|
export class MaxTurnsError extends Error {
|
|
56
|
-
constructor({ provider, model, turns, action, toolCalls = [] }) {
|
|
57
|
-
// O que o modelo ficou fazendo é a informação que decide o remédio: muitas
|
|
58
|
-
// leituras distintas sugerem teto baixo; a mesma chamada repetida é loop
|
|
59
|
-
// degenerado, e aí subir o teto só encarece.
|
|
96
|
+
constructor({ provider, model, turns, action, toolCalls = [], toolSignatures = [] }) {
|
|
60
97
|
const resumo = toolCalls.length > 0
|
|
61
98
|
? ` Ferramentas mais chamadas: ${summarizeToolCalls(toolCalls)}.`
|
|
62
99
|
: '';
|
|
100
|
+
const exploracao = looksLikeExploration(toolSignatures);
|
|
101
|
+
const remedio = exploracao
|
|
102
|
+
? 'As chamadas são majoritariamente DISTINTAS — o modelo gastou o orçamento explorando, ' +
|
|
103
|
+
'o que varia entre execuções. Uma repetição costuma bastar (a CLI já faz UMA, ' +
|
|
104
|
+
'automaticamente); se insistir, suba `maxTurns` no prompt em vez de trocar o modelo.'
|
|
105
|
+
: 'As chamadas se REPETEM — é loop degenerado, e repetir reproduz a mesma perambulação ' +
|
|
106
|
+
'mais cara. Troque o modelo (por `ai.models` ou pela label ' +
|
|
107
|
+
'`spec-wave:model:<apelido>`) ou suba o teto de turnos.';
|
|
63
108
|
super(
|
|
64
109
|
`O modelo esgotou o teto de ${turns} turnos sem produzir o documento ` +
|
|
65
110
|
`(${provider} · ${model}${action ? ` · ação=${action}` : ''}): respondeu com chamadas de ` +
|
|
66
|
-
`ferramenta em todos eles.${resumo} `
|
|
67
|
-
'Repetir não ajuda — o erro é determinístico. Ou o modelo não fecha o loop de ' +
|
|
68
|
-
'ferramentas nesta tarefa (troque-o, por `ai.models` ou pela label ' +
|
|
69
|
-
'`spec-wave:model:<apelido>`), ou a exploração precisa de mais turnos.'
|
|
111
|
+
`ferramenta em todos eles.${resumo} ${remedio}`
|
|
70
112
|
);
|
|
71
113
|
this.name = 'MaxTurnsError';
|
|
72
114
|
this.maxTurns = true;
|
|
115
|
+
// Quem decide o retry (lib/claude.mjs) lê esta flag — a classificação mora
|
|
116
|
+
// aqui, junto dos dados que a sustentam.
|
|
117
|
+
this.exploration = exploracao;
|
|
73
118
|
this.turns = turns;
|
|
74
119
|
this.action = action || null;
|
|
75
120
|
this.toolCalls = toolCalls;
|
|
121
|
+
this.toolSignatures = toolSignatures;
|
|
76
122
|
}
|
|
77
123
|
}
|
|
78
124
|
|
package/src/agent/index.mjs
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
// Registro de backends: transforma um
|
|
1
|
+
// Registro de backends: transforma um backend num runner concreto.
|
|
2
2
|
// Porte de `agent-cli/src/workflow/backends.ts` + `src/lib.ts`.
|
|
3
3
|
//
|
|
4
|
-
// É o ÚNICO módulo que sabe que os dois backends existem, então um
|
|
5
|
-
//
|
|
4
|
+
// É o ÚNICO módulo que sabe que os dois backends existem, então um MOTOR novo
|
|
5
|
+
// significa mexer só aqui. Um provider novo que reusa um motor existente
|
|
6
|
+
// (`claude-oauth` sobre o backend anthropic) não passa por este arquivo — vive
|
|
7
|
+
// inteiro em config.mjs.
|
|
8
|
+
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
6
10
|
|
|
7
11
|
import { runAnthropicAgent } from './anthropic-agent.mjs';
|
|
8
12
|
import { runOpenRouterAgent } from './openrouter-agent.mjs';
|
|
@@ -12,7 +16,13 @@ export { ENV_FILE_PATTERN, createToolContext, executeTool, toolDefinitionsFor, K
|
|
|
12
16
|
export { initTelemetry, shutdownTelemetry, telemetryConfigured } from './telemetry.mjs';
|
|
13
17
|
export { runAnthropicAgent, runOpenRouterAgent };
|
|
14
18
|
|
|
15
|
-
|
|
19
|
+
// Este módulo fala em BACKEND, não em provider: quem escolhe entre `anthropic`,
|
|
20
|
+
// `claude-oauth` e `openrouter` é o config, e src/lib/claude.mjs traduz essa
|
|
21
|
+
// escolha para o backend antes de chegar aqui. Assim um provider novo que reusa
|
|
22
|
+
// um backend existente (foi o caso do claude-oauth) não encosta em src/agent/.
|
|
23
|
+
export const BACKENDS = ['anthropic', 'openrouter'];
|
|
24
|
+
/** @deprecated nome antigo de BACKENDS — mantido para não quebrar importadores. */
|
|
25
|
+
export const PROVIDERS = BACKENDS;
|
|
16
26
|
|
|
17
27
|
const RUNNERS = {
|
|
18
28
|
anthropic: runAnthropicAgent,
|
|
@@ -20,16 +30,16 @@ const RUNNERS = {
|
|
|
20
30
|
};
|
|
21
31
|
|
|
22
32
|
/**
|
|
23
|
-
* Runner do
|
|
33
|
+
* Runner do backend (função PURA).
|
|
24
34
|
*
|
|
25
|
-
* @param {'anthropic'|'openrouter'}
|
|
35
|
+
* @param {'anthropic'|'openrouter'} backend
|
|
26
36
|
* @returns {(prompt: string, options: object) => Promise<object>}
|
|
27
37
|
*/
|
|
28
|
-
export function runnerFor(
|
|
29
|
-
const runner = RUNNERS[
|
|
38
|
+
export function runnerFor(backend) {
|
|
39
|
+
const runner = RUNNERS[backend];
|
|
30
40
|
if (!runner) {
|
|
31
41
|
throw new Error(
|
|
32
|
-
`
|
|
42
|
+
`Backend desconhecido: "${backend}" (esperado um de: ${BACKENDS.join(', ')}).`,
|
|
33
43
|
);
|
|
34
44
|
}
|
|
35
45
|
return runner;
|
|
@@ -39,20 +49,31 @@ export function runnerFor(provider) {
|
|
|
39
49
|
* Confere credenciais ANTES de qualquer chamada de modelo — um fluxo que morre
|
|
40
50
|
* no meio por falta de chave já gastou dinheiro de verdade.
|
|
41
51
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
52
|
+
* A checagem é por BACKEND, e o backend anthropic aceita qualquer uma das duas
|
|
53
|
+
* credenciais: chave de API (provider `anthropic`) ou token da assinatura
|
|
54
|
+
* (provider `claude-oauth`). Quem exige a credencial CERTA para o provider
|
|
55
|
+
* escolhido é o `doctor`, que sabe qual dos dois o repo configurou.
|
|
56
|
+
*
|
|
57
|
+
* Fora do CI o backend anthropic passa mesmo sem nada no ambiente: quem está
|
|
58
|
+
* logado no Claude Code (`claude /login`) tem a credencial guardada pelo
|
|
59
|
+
* próprio CLI, não em variável, e barrar aí rejeitaria uma sessão que
|
|
60
|
+
* funciona. No Actions não existe login interativo, então lá a ausência
|
|
61
|
+
* continua sendo erro imediato.
|
|
44
62
|
*
|
|
45
|
-
* @param {'anthropic'|'openrouter'}
|
|
63
|
+
* @param {'anthropic'|'openrouter'} backend
|
|
46
64
|
* @param {object} [env]
|
|
47
65
|
*/
|
|
48
|
-
export function assertCredentials(
|
|
49
|
-
if (
|
|
66
|
+
export function assertCredentials(backend, env = process.env) {
|
|
67
|
+
if (
|
|
68
|
+
backend === 'anthropic' && env.GITHUB_ACTIONS === 'true' &&
|
|
69
|
+
!env.ANTHROPIC_API_KEY && !env.CLAUDE_CODE_OAUTH_TOKEN
|
|
70
|
+
) {
|
|
50
71
|
throw new Error(
|
|
51
72
|
'Faltam credenciais: defina ANTHROPIC_API_KEY ou CLAUDE_CODE_OAUTH_TOKEN ' +
|
|
52
73
|
'(secret do repositório, em Settings → Secrets → Actions).',
|
|
53
74
|
);
|
|
54
75
|
}
|
|
55
|
-
if (
|
|
76
|
+
if (backend === 'openrouter' && !env.OPENROUTER_API_KEY) {
|
|
56
77
|
throw new Error(
|
|
57
78
|
'Faltam credenciais: defina OPENROUTER_API_KEY ' +
|
|
58
79
|
'(secret do repositório, em Settings → Secrets → Actions).',
|
|
@@ -60,49 +81,94 @@ export function assertCredentials(provider, env = process.env) {
|
|
|
60
81
|
}
|
|
61
82
|
}
|
|
62
83
|
|
|
84
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Pacotes que podem carregar o binário nativo do Claude Code nesta plataforma.
|
|
88
|
+
*
|
|
89
|
+
* No linux há duas variantes de libc e o npm instala só a que casa com o host;
|
|
90
|
+
* aceitar as duas evita um falso negativo em imagem musl (Alpine).
|
|
91
|
+
*
|
|
92
|
+
* @param {NodeJS.Platform} [platform]
|
|
93
|
+
* @param {string} [arch]
|
|
94
|
+
* @returns {string[]}
|
|
95
|
+
*/
|
|
96
|
+
export function nativeBinaryPackages(platform = process.platform, arch = process.arch) {
|
|
97
|
+
const base = `@anthropic-ai/claude-agent-sdk-${platform}-${arch}`;
|
|
98
|
+
return platform === 'linux' ? [base, `${base}-musl`] : [base];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* O binário nativo do Claude Code está instalado?
|
|
103
|
+
*
|
|
104
|
+
* @param {(id: string) => string} [resolve] injetável para teste
|
|
105
|
+
* @returns {boolean}
|
|
106
|
+
*/
|
|
107
|
+
export function nativeBinaryAvailable(resolve = id => requireFromHere.resolve(id)) {
|
|
108
|
+
return nativeBinaryPackages().some(pkg => {
|
|
109
|
+
try {
|
|
110
|
+
resolve(`${pkg}/package.json`);
|
|
111
|
+
return true;
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
63
118
|
/**
|
|
64
|
-
* O backend pode rodar NESTE ambiente?
|
|
119
|
+
* O backend pode rodar NESTE ambiente?
|
|
65
120
|
*
|
|
66
121
|
* O backend anthropic não chama a API: `query()` sobe o **Claude Code CLI como
|
|
67
|
-
* processo filho**.
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
122
|
+
* processo filho**. O que decide se isso funciona é a presença do binário — e
|
|
123
|
+
* ele vem EMBARCADO no `@anthropic-ai/claude-agent-sdk`, como dependência
|
|
124
|
+
* opcional por plataforma (`@anthropic-ai/claude-agent-sdk-linux-x64` e
|
|
125
|
+
* companhia). Ou seja: `npm install -g @spec-wave/cli` já o traz, inclusive no
|
|
126
|
+
* runner do GitHub Actions, que só tem `setup-node`.
|
|
127
|
+
*
|
|
128
|
+
* Isto já foi um bloqueio incondicional no Actions, escrito quando o SDK ainda
|
|
129
|
+
* exigia o Claude Code instalado à parte. Checar o binário em vez do ambiente
|
|
130
|
+
* cobre a falha de verdade — `npm install --omit=optional`, plataforma sem
|
|
131
|
+
* build publicado — e libera a assinatura (provider `claude-oauth`) no CI.
|
|
132
|
+
*
|
|
133
|
+
* Fora do alcance daqui: binário presente mas com a libc errada (musl num host
|
|
134
|
+
* glibc). Aí quem falha é o spawn, e a mensagem do próprio SDK já nomeia a causa.
|
|
71
135
|
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
136
|
+
* `SPEC_WAVE_ALLOW_ANTHROPIC_IN_CI=1` segue aceito como bypass — hoje inútil,
|
|
137
|
+
* mas está em workflows de quem seguiu a orientação antiga.
|
|
74
138
|
*
|
|
75
|
-
* @param {'anthropic'|'openrouter'}
|
|
139
|
+
* @param {'anthropic'|'openrouter'} backend
|
|
76
140
|
* @param {object} [env]
|
|
77
141
|
*/
|
|
78
|
-
export function assertBackendRunnable(
|
|
79
|
-
if (
|
|
80
|
-
if (env.GITHUB_ACTIONS !== 'true') return;
|
|
142
|
+
export function assertBackendRunnable(backend, env = process.env) {
|
|
143
|
+
if (backend !== 'anthropic') return;
|
|
81
144
|
if (env.SPEC_WAVE_ALLOW_ANTHROPIC_IN_CI) return;
|
|
145
|
+
if (nativeBinaryAvailable()) return;
|
|
82
146
|
throw new Error(
|
|
83
|
-
'O
|
|
84
|
-
|
|
147
|
+
'O backend `anthropic` sobe o Claude Code CLI como subprocesso, e o binário nativo ' +
|
|
148
|
+
`não está instalado (procurado em: ${nativeBinaryPackages().join(', ')}).\n\n` +
|
|
85
149
|
'Saídas:\n' +
|
|
86
|
-
' 1.
|
|
87
|
-
'
|
|
88
|
-
' 2. ou
|
|
89
|
-
'
|
|
150
|
+
' 1. Reinstale o CLI SEM `--omit=optional` (o binário é uma dependência opcional ' +
|
|
151
|
+
'por plataforma do @anthropic-ai/claude-agent-sdk);\n' +
|
|
152
|
+
' 2. ou troque para o OpenRouter no .spec-wave.json: "ai": { "provider": "openrouter", ' +
|
|
153
|
+
'"model": "anthropic/claude-opus-4.8" } e adicione o secret OPENROUTER_API_KEY.',
|
|
90
154
|
);
|
|
91
155
|
}
|
|
92
156
|
|
|
93
157
|
/**
|
|
94
|
-
* Executa uma ação de IA no
|
|
158
|
+
* Executa uma ação de IA no backend escolhido.
|
|
95
159
|
*
|
|
96
160
|
* É o ponto único por onde `lib/claude.mjs` fala com o motor: os dois backends
|
|
97
|
-
* implementam o mesmo contrato, então quem chama nunca ramifica por
|
|
161
|
+
* implementam o mesmo contrato, então quem chama nunca ramifica por backend.
|
|
162
|
+
* O campo continua se chamando `provider` por compatibilidade com o contrato do
|
|
163
|
+
* agent-cli de origem, mas o valor esperado é o BACKEND.
|
|
98
164
|
*
|
|
99
165
|
* @param {string} prompt conteúdo do usuário
|
|
100
166
|
* @param {import('./run-types.mjs').AgentRunOptions & {provider: string}} options
|
|
101
167
|
* @returns {Promise<import('./run-types.mjs').AgentRunResult>}
|
|
102
168
|
*/
|
|
103
169
|
export async function runAgent(prompt, options) {
|
|
104
|
-
const { provider, ...rest } = options;
|
|
105
|
-
assertBackendRunnable(
|
|
106
|
-
assertCredentials(
|
|
107
|
-
return runnerFor(
|
|
170
|
+
const { provider: backend, ...rest } = options;
|
|
171
|
+
assertBackendRunnable(backend);
|
|
172
|
+
assertCredentials(backend);
|
|
173
|
+
return runnerFor(backend)(prompt, rest);
|
|
108
174
|
}
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
toolDefinitionsFor,
|
|
23
23
|
} from './tools.mjs';
|
|
24
24
|
import { withTrace, startObservation, updateSpan, endObservation } from './tracing.mjs';
|
|
25
|
-
import { TruncatedOutputError, isTruncationReason, summarizeToolCalls } from './errors.mjs';
|
|
25
|
+
import { TruncatedOutputError, isTruncationReason, summarizeToolCalls, toolSignature } from './errors.mjs';
|
|
26
26
|
|
|
27
27
|
const DEFAULT_BASE_URL = 'https://openrouter.ai/api/v1';
|
|
28
28
|
const DEFAULT_TOOLS = ['Read', 'Glob', 'Grep'];
|
|
@@ -184,6 +184,7 @@ export async function runOpenRouterAgent(prompt, options) {
|
|
|
184
184
|
// turnos sem escrever nada, o log não dizia no que ele se perdeu, e o
|
|
185
185
|
// diagnóstico exigiu reconstruir o run à mão.
|
|
186
186
|
toolCalls: [],
|
|
187
|
+
toolSignatures: [],
|
|
187
188
|
};
|
|
188
189
|
const quiet = options.quiet !== false;
|
|
189
190
|
|
|
@@ -333,6 +334,9 @@ export async function runOpenRouterAgent(prompt, options) {
|
|
|
333
334
|
}
|
|
334
335
|
|
|
335
336
|
runResult.toolCalls.push(call.function.name);
|
|
337
|
+
// Assinatura (nome + alvo) separa exploração de loop degenerado —
|
|
338
|
+
// é o que decide se o teto de turnos merece uma repetição.
|
|
339
|
+
runResult.toolSignatures.push(toolSignature(call.function.name, parsedInput));
|
|
336
340
|
if (options.verbose) console.log(`\n[Tool] ${call.function.name}(${call.function.arguments})`);
|
|
337
341
|
|
|
338
342
|
const observation = startObservation(
|
|
@@ -249,6 +249,69 @@ export function isAlreadyInProjectError(err) {
|
|
|
249
249
|
|| /content already exists/i.test(msg);
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Todos os itens do Project, com os campos SINGLE_SELECT resolvidos por NOME.
|
|
254
|
+
*
|
|
255
|
+
* Uma query paginada no lugar de duas chamadas POR ITEM
|
|
256
|
+
* (`addProjectItem` + `getItemSingleSelectValue`), que é como o `order` lia a
|
|
257
|
+
* Etapa de cada Story. Com nove Features na tela isso passava de cem chamadas
|
|
258
|
+
* para montar um mapa que cabe em duas.
|
|
259
|
+
*
|
|
260
|
+
* Só itens cujo conteúdo é Issue entram (draft e PR ficam de fora).
|
|
261
|
+
*
|
|
262
|
+
* @returns {Promise<Array<{number, title, state, nodeId, itemId, fields: Record<string,string>}>>}
|
|
263
|
+
*/
|
|
264
|
+
export async function listProjectItems(token, projectId) {
|
|
265
|
+
const client = makeClient(token);
|
|
266
|
+
const itens = [];
|
|
267
|
+
let after = null;
|
|
268
|
+
do {
|
|
269
|
+
const result = await client(`
|
|
270
|
+
query ProjectItems($id: ID!, $after: String) {
|
|
271
|
+
node(id: $id) {
|
|
272
|
+
... on ProjectV2 {
|
|
273
|
+
items(first: 100, after: $after) {
|
|
274
|
+
pageInfo { hasNextPage endCursor }
|
|
275
|
+
nodes {
|
|
276
|
+
id
|
|
277
|
+
fieldValues(first: 20) {
|
|
278
|
+
nodes {
|
|
279
|
+
... on ProjectV2ItemFieldSingleSelectValue {
|
|
280
|
+
name
|
|
281
|
+
field { ... on ProjectV2SingleSelectField { name } }
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
content { ... on Issue { number title state id } }
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
`, { id: projectId, after });
|
|
292
|
+
const page = result.node?.items;
|
|
293
|
+
for (const node of page?.nodes || []) {
|
|
294
|
+
const issue = node?.content;
|
|
295
|
+
if (!issue?.number) continue;
|
|
296
|
+
const fields = {};
|
|
297
|
+
for (const fv of node.fieldValues?.nodes || []) {
|
|
298
|
+
const nome = fv?.field?.name;
|
|
299
|
+
if (nome && fv.name) fields[nome] = fv.name;
|
|
300
|
+
}
|
|
301
|
+
itens.push({
|
|
302
|
+
number: issue.number,
|
|
303
|
+
title: issue.title,
|
|
304
|
+
state: issue.state,
|
|
305
|
+
nodeId: issue.id,
|
|
306
|
+
itemId: node.id,
|
|
307
|
+
fields,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
after = page?.pageInfo?.hasNextPage ? page.pageInfo.endCursor : null;
|
|
311
|
+
} while (after);
|
|
312
|
+
return itens;
|
|
313
|
+
}
|
|
314
|
+
|
|
252
315
|
// Cria a relação de sub-issue nativa do GitHub (parent → child). Ambos os IDs
|
|
253
316
|
// são node ids de Issue. Faz com que o filho exiba o pai e vice-versa na UI.
|
|
254
317
|
export async function addSubIssue(token, parentIssueId, childIssueId) {
|
package/src/api/github-rest.mjs
CHANGED
|
@@ -281,9 +281,16 @@ export async function ensurePullRequest(
|
|
|
281
281
|
|
|
282
282
|
// `id` é o database id da issue — exigido pela API de dependências
|
|
283
283
|
// (blocked_by), que não aceita number nem node id.
|
|
284
|
-
|
|
284
|
+
//
|
|
285
|
+
// `milestone` é o NÚMERO do milestone no repo (não o título nem o node id) e só
|
|
286
|
+
// entra no payload quando existe: a API rejeita `milestone: null` com 422 em vez
|
|
287
|
+
// de tratar como "sem milestone".
|
|
288
|
+
export async function createIssue(token, owner, repo, title, body, labels, { milestone } = {}) {
|
|
285
289
|
const octokit = makeOctokit(token);
|
|
286
|
-
const res = await octokit.rest.issues.create({
|
|
290
|
+
const res = await octokit.rest.issues.create({
|
|
291
|
+
owner, repo, title, body, labels,
|
|
292
|
+
...(milestone ? { milestone } : {}),
|
|
293
|
+
});
|
|
287
294
|
return { number: res.data.number, nodeId: res.data.node_id, url: res.data.html_url, id: res.data.id };
|
|
288
295
|
}
|
|
289
296
|
|