@spec-wave/cli 0.23.0 → 0.25.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 +109 -5
- package/bin/spec-wave.mjs +35 -2
- package/package.json +1 -1
- package/src/agent/anthropic-agent.mjs +40 -19
- package/src/agent/index.mjs +104 -38
- package/src/api/github-rest.mjs +63 -1
- package/src/commands/decompose.mjs +3 -10
- package/src/commands/doctor.mjs +70 -12
- package/src/commands/generate-bug.mjs +8 -14
- package/src/commands/generate-plan.mjs +2 -1
- package/src/commands/generate-spec.mjs +2 -1
- package/src/commands/init.mjs +5 -0
- package/src/commands/mode.mjs +169 -0
- package/src/commands/run.mjs +396 -0
- package/src/commands/validate.mjs +22 -6
- package/src/config.mjs +40 -6
- package/src/lib/claude.mjs +13 -3
- package/src/lib/config-file.mjs +62 -0
- package/src/lib/doc-paths.mjs +51 -0
- package/src/lib/execution-mode.mjs +110 -0
- package/src/lib/next-step.mjs +356 -0
- package/src/lib/pr-step.mjs +102 -0
- package/src/lib/repo-links.mjs +84 -0
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/skills/doctor/SKILL.md +2 -1
- package/src/plugin/skills/run/SKILL.md +76 -0
- package/src/plugin/skills/setup/SKILL.md +1 -1
- package/src/plugin/skills/spec/SKILL.md +1 -1
- package/src/templates/skill/SKILL.md +25 -2
- package/src/templates/workflows/code-review.yml +5 -1
- package/src/templates/workflows/critique.yml +5 -0
- package/src/templates/workflows/decompose.yml +5 -0
- package/src/templates/workflows/generate-bug.yml +5 -0
- package/src/templates/workflows/generate-plan.yml +5 -0
- package/src/templates/workflows/generate-spec.yml +5 -0
- package/src/templates/workflows/qa.yml +6 -1
- package/src/templates/workflows/validate.yml +4 -0
package/README.md
CHANGED
|
@@ -108,11 +108,29 @@ 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
|
---
|
|
115
115
|
|
|
116
|
+
## Modo de execução: GitHub Actions ou local
|
|
117
|
+
|
|
118
|
+
Todo passo do fluxo roda nos dois lugares — os workflows só instalam a CLI e
|
|
119
|
+
chamam um comando. Para conduzir tudo sem consumir minutos de Actions:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
npx @spec-wave/cli@latest mode local # desarma os workflows (variável SPEC_WAVE_EXECUTION)
|
|
123
|
+
npx @spec-wave/cli@latest run <issue> # executa aqui o passo que a label dispararia
|
|
124
|
+
npx @spec-wave/cli@latest mode actions # volta ao CI
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Com a variável setada, cada job é pulado e o run aparece como *skipped* — job que
|
|
128
|
+
não roda não é faturado. `run <issue> --dry-run` explica o próximo passo (e o
|
|
129
|
+
porquê) sem executar nada. Detalhes em
|
|
130
|
+
[`packages/spec-wave/README.md`](packages/spec-wave/README.md#modo-de-execução-actions-ou-local).
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
116
134
|
## Instalação da CLI
|
|
117
135
|
|
|
118
136
|
Não é necessário instalar globalmente — use `npx`:
|
|
@@ -130,6 +148,24 @@ spec-wave --help
|
|
|
130
148
|
|
|
131
149
|
---
|
|
132
150
|
|
|
151
|
+
## Modo de execução: GitHub Actions ou local
|
|
152
|
+
|
|
153
|
+
Todo passo do fluxo roda nos dois lugares — os workflows só instalam a CLI e
|
|
154
|
+
chamam um comando. Para conduzir tudo sem consumir minutos de Actions:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
npx @spec-wave/cli@latest mode local # desarma os workflows (variável SPEC_WAVE_EXECUTION)
|
|
158
|
+
npx @spec-wave/cli@latest run <issue> # executa aqui o passo que a label dispararia
|
|
159
|
+
npx @spec-wave/cli@latest mode actions # volta ao CI
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Com a variável setada, cada job é pulado e o run aparece como *skipped* — job que
|
|
163
|
+
não roda não é faturado. `run <issue> --dry-run` explica o próximo passo (e o
|
|
164
|
+
porquê) sem executar nada. Detalhes em
|
|
165
|
+
[`packages/spec-wave/README.md`](packages/spec-wave/README.md#modo-de-execução-actions-ou-local).
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
133
169
|
## Instalação da Skill
|
|
134
170
|
|
|
135
171
|
A skill permite usar o fluxo diretamente no seu agente via `/spec-wave`.
|
|
@@ -191,10 +227,10 @@ npx @spec-wave/cli@latest init --repo acme/loja --project-title "Loja — Spec W
|
|
|
191
227
|
O `init` cria:
|
|
192
228
|
- GitHub Project v2 com 12 colunas Kanban e campos personalizados (Work Item Type, Priority, Story Points, Area)
|
|
193
229
|
- 20+ labels de tipo, prioridade e gatilho
|
|
194
|
-
-
|
|
230
|
+
- 8 GitHub Actions workflows em `.github/workflows/`
|
|
195
231
|
- `.spec-wave.json` com os IDs do Project
|
|
196
232
|
|
|
197
|
-
Adicionar o secret de IA no GitHub: **Settings → Secrets → Actions
|
|
233
|
+
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
234
|
|
|
199
235
|
---
|
|
200
236
|
|
|
@@ -408,14 +444,82 @@ Isso atualiza apenas os arquivos de workflow sem recriar o Project ou as labels.
|
|
|
408
444
|
|
|
409
445
|
| Provider | Secret | Modelo padrão |
|
|
410
446
|
|----------|--------|---------------|
|
|
411
|
-
|
|
|
412
|
-
|
|
|
447
|
+
| `anthropic` | `ANTHROPIC_API_KEY` | `claude-sonnet-4-6` |
|
|
448
|
+
| `claude-oauth` | `CLAUDE_CODE_OAUTH_TOKEN` | `claude-sonnet-4-6` |
|
|
449
|
+
| `openrouter` | `OPENROUTER_API_KEY` | `anthropic/claude-3.7-sonnet` |
|
|
413
450
|
|
|
414
451
|
Configurar no `init`:
|
|
415
452
|
```bash
|
|
416
453
|
npx @spec-wave/cli@latest init --repo owner/repo --provider openrouter --model anthropic/claude-3.7-sonnet
|
|
417
454
|
```
|
|
418
455
|
|
|
456
|
+
### `claude-oauth` — rodar tudo pela assinatura Claude Pro/Max
|
|
457
|
+
|
|
458
|
+
Mesmo motor do provider `anthropic`, credencial diferente: em vez de uma chave
|
|
459
|
+
de API cobrada por token, o token da sua assinatura. Os cinco workflows de IA
|
|
460
|
+
(spec, plan, bug, decompose, critique) rodam assim, inclusive nos GitHub Actions
|
|
461
|
+
— o binário do Claude Code vem embarcado na CLI, então o runner não precisa de
|
|
462
|
+
nada além do `setup-node` que os workflows já fazem.
|
|
463
|
+
|
|
464
|
+
```bash
|
|
465
|
+
claude setup-token # gere o token (escopo só de inferência)
|
|
466
|
+
# cole o valor em Settings → Secrets → Actions → CLAUDE_CODE_OAUTH_TOKEN
|
|
467
|
+
|
|
468
|
+
npx @spec-wave/cli@latest init --repo owner/repo --provider claude-oauth
|
|
469
|
+
# ou, num repo já configurado, troque "provider" no .spec-wave.json e rode `update`
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
O consumo passa a sair do limite do seu plano — sob rate limit da assinatura o
|
|
473
|
+
job falha por indisponibilidade, não por erro de configuração.
|
|
474
|
+
|
|
475
|
+
---
|
|
476
|
+
|
|
477
|
+
## Modo de execução: Actions ou local
|
|
478
|
+
|
|
479
|
+
Os workflows nunca fizeram o trabalho — eles instalam a CLI e chamam um comando.
|
|
480
|
+
Por isso o fluxo inteiro roda igual na sua máquina, e dá para **desligar o CI**
|
|
481
|
+
quando o que incomoda é o consumo de minutos.
|
|
482
|
+
|
|
483
|
+
```bash
|
|
484
|
+
npx @spec-wave/cli@latest mode # estado atual
|
|
485
|
+
npx @spec-wave/cli@latest mode local # desarma os workflows
|
|
486
|
+
npx @spec-wave/cli@latest run <issue> --dry-run # explica o próximo passo
|
|
487
|
+
npx @spec-wave/cli@latest run <issue> # executa aqui
|
|
488
|
+
npx @spec-wave/cli@latest mode actions # volta ao CI
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
`mode` escreve os **dois** lados do interruptor: `execution.mode` no
|
|
492
|
+
`.spec-wave.json` (o que a CLI e as skills leem) e a variável de repositório
|
|
493
|
+
`SPEC_WAVE_EXECUTION` (o que o `if:` de cada job avalia). Com ela setada, o run
|
|
494
|
+
aparece como *skipped* — **job que não roda não é faturado**. A variável exige
|
|
495
|
+
admin no repositório; sem permissão o comando avisa em vez de fingir que aplicou.
|
|
496
|
+
|
|
497
|
+
`run <issue>` executa o passo que a label dispararia, decidido pelo estado da
|
|
498
|
+
issue: documentos que existem + labels presentes.
|
|
499
|
+
|
|
500
|
+
| Estado | Passo |
|
|
501
|
+
|---|---|
|
|
502
|
+
| Feature sem `spec.md` | `generate-spec` |
|
|
503
|
+
| spec pronta, sem `plan.md` | `generate-plan` (crítica embutida) |
|
|
504
|
+
| sem `spec-wave:plan-approved` | `validate` |
|
|
505
|
+
| validada, sem `decomposition.md` | `decompose` (rascunho) |
|
|
506
|
+
| `spec-wave:decompose-ready` | `decompose-apply` — exige `--apply` |
|
|
507
|
+
| Bug | `generate-bug` → `validate` → triagem (humana) |
|
|
508
|
+
|
|
509
|
+
`run --pr <n>` cobre o lado do PR: `code-review` e, havendo review aprovada,
|
|
510
|
+
também `qa`.
|
|
511
|
+
|
|
512
|
+
Ele **se recusa** a rodar (saindo com código 2) quando há label de gatilho
|
|
513
|
+
pendente na issue — Action em voo, e rodar por cima duplicaria o documento —,
|
|
514
|
+
quando um portão humano da crítica está aplicado (`needs-human`,
|
|
515
|
+
`critique-failed`), quando o documento existe no repositório mas não no seu
|
|
516
|
+
clone (`git pull` primeiro) e quando o passo cria issues sem confirmação
|
|
517
|
+
explícita. `--dry-run` mostra a decisão sem executar nada; `--force`, `--yes`,
|
|
518
|
+
`--apply` e `--step` cobrem os casos em que você sabe o que está fazendo.
|
|
519
|
+
|
|
520
|
+
O `doctor` tem um check para o par config × variável: divergir é o estado
|
|
521
|
+
perigoso — é achar que desligou o CI e continuar pagando por ele.
|
|
522
|
+
|
|
419
523
|
---
|
|
420
524
|
|
|
421
525
|
## Licença
|
package/bin/spec-wave.mjs
CHANGED
|
@@ -128,6 +128,35 @@ program
|
|
|
128
128
|
.catch(err => { console.error(err.message); process.exit(1); });
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
+
program
|
|
132
|
+
.command('run')
|
|
133
|
+
.description('Executa LOCALMENTE o próximo passo do fluxo (o que a label dispararia no Actions)')
|
|
134
|
+
.argument('[issue]', 'Número da issue (Feature, Bug ou RFC)')
|
|
135
|
+
.option('--pr <n>', 'Modo PR: decide entre code-review e qa pelo estado das reviews')
|
|
136
|
+
.option('--dry-run', 'Decide e explica sem executar nada')
|
|
137
|
+
.option('--yes', 'Confirma o passo que exige confirmação')
|
|
138
|
+
.option('--apply', 'Autoriza especificamente o decompose-apply (erra se o passo pendente for outro)')
|
|
139
|
+
.option('--step <nome>', 'Força um passo: spec | plan | critique | validate | decompose | decompose-apply | bug')
|
|
140
|
+
.option('--max-steps <n>', 'Encadeia até N passos (padrão: 1)', '1')
|
|
141
|
+
.option('--force', 'Ignora o portão de label de gatilho pendente')
|
|
142
|
+
.option('--no-remote-check', 'Não consulta o remoto pelos documentos ausentes (offline)')
|
|
143
|
+
.option('--only <passo>', 'No modo --pr: roda só code-review ou só qa')
|
|
144
|
+
.option('--json', 'Imprime a decisão em JSON')
|
|
145
|
+
.action(async (issue, options) => {
|
|
146
|
+
const { run } = await import('../src/commands/run.mjs');
|
|
147
|
+
await run(issue, options).catch(err => { console.error(err.message); process.exit(1); });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
program
|
|
151
|
+
.command('mode')
|
|
152
|
+
.description('Mostra ou alterna o modo de execução: `actions` (workflows) ou `local` (esta máquina)')
|
|
153
|
+
.argument('[modo]', 'actions | local (sem argumento: só mostra o estado)')
|
|
154
|
+
.option('--dry-run', 'Mostra o que mudaria sem alterar nada')
|
|
155
|
+
.action(async (target, options) => {
|
|
156
|
+
const { mode } = await import('../src/commands/mode.mjs');
|
|
157
|
+
await mode({ target, ...options }).catch(err => { console.error(err.message); process.exit(1); });
|
|
158
|
+
});
|
|
159
|
+
|
|
131
160
|
program
|
|
132
161
|
.command('update')
|
|
133
162
|
.description('Detecta o que está desatualizado (skill, .spec-wave.json, workflows/labels do repo) e atualiza só o que mudou')
|
|
@@ -205,7 +234,7 @@ program
|
|
|
205
234
|
|
|
206
235
|
program
|
|
207
236
|
.command('generate-bug')
|
|
208
|
-
.description('Gera bug.md para um Bug
|
|
237
|
+
.description('Gera bug.md para um Bug — roda no GitHub Action ou localmente')
|
|
209
238
|
.requiredOption('--issue-number <n>', 'Número da issue no GitHub')
|
|
210
239
|
.action(async (options) => {
|
|
211
240
|
const { generateBug } = await import('../src/commands/generate-bug.mjs');
|
|
@@ -218,7 +247,11 @@ program
|
|
|
218
247
|
.requiredOption('--issue-number <n>', 'Número da issue no GitHub')
|
|
219
248
|
.action(async (options) => {
|
|
220
249
|
const { validate } = await import('../src/commands/validate.mjs');
|
|
221
|
-
|
|
250
|
+
// A reprova é um desfecho esperado do comando, não uma exceção — mas o exit
|
|
251
|
+
// code precisa continuar 1 para o job do Actions ficar vermelho.
|
|
252
|
+
const result = await validate(options)
|
|
253
|
+
.catch(err => { console.error(err.message); process.exit(1); });
|
|
254
|
+
if (result?.ok === false) process.exit(1);
|
|
222
255
|
});
|
|
223
256
|
|
|
224
257
|
program
|
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:
|
|
@@ -31,6 +31,18 @@ import { TruncatedOutputError } from './errors.mjs';
|
|
|
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,
|
|
@@ -98,6 +112,7 @@ export async function runAnthropicAgent(prompt, options) {
|
|
|
98
112
|
const queryOptions = {
|
|
99
113
|
model: options.model,
|
|
100
114
|
maxTurns: options.maxTurns ?? MAX_TURNS,
|
|
115
|
+
...(schema ? { outputFormat: { type: 'json_schema', schema: schema.jsonSchema } } : {}),
|
|
101
116
|
// `tools` restringe o que o modelo sequer VÊ; `allowedTools` é só a
|
|
102
117
|
// lista de auto-aprovação sobre essa superfície. Sem `tools`, as ~31
|
|
103
118
|
// tools do Claude Code aparecem e são apenas aprovadas/negadas por
|
|
@@ -123,7 +138,12 @@ export async function runAnthropicAgent(prompt, options) {
|
|
|
123
138
|
],
|
|
124
139
|
}
|
|
125
140
|
: {}),
|
|
126
|
-
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),
|
|
127
147
|
// Desliga o carregamento de settings do filesystem (~/.claude,
|
|
128
148
|
// .claude/settings.json) para o subprocesso não herdar hooks ou
|
|
129
149
|
// permissões de fora deste `queryOptions`.
|
|
@@ -290,6 +310,7 @@ export async function runAnthropicAgent(prompt, options) {
|
|
|
290
310
|
if (message.subtype === 'success') {
|
|
291
311
|
output = message.result;
|
|
292
312
|
runResult.outputText = message.result;
|
|
313
|
+
if (schema) runResult.structured = message.structured_output ?? null;
|
|
293
314
|
if (!quiet && !printedAnyText && message.result.trim() !== '') {
|
|
294
315
|
console.log(`\nClaude: ${message.result}`);
|
|
295
316
|
}
|
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
|
}
|
package/src/api/github-rest.mjs
CHANGED
|
@@ -445,7 +445,9 @@ export async function getPR(token, owner, repo, prNumber) {
|
|
|
445
445
|
// `ref` é opcional (compatível com os chamadores de 4 argumentos): o modo PR do
|
|
446
446
|
// `update` precisa ler o .spec-wave.json na BASE, não no que a API escolher.
|
|
447
447
|
export async function getFileContent(token, owner, repo, path, ref) {
|
|
448
|
-
|
|
448
|
+
// Quiet: 404 é resultado esperado aqui (o arquivo pode não existir), e a linha
|
|
449
|
+
// `GET ... - 404` no stderr parecia falha no meio da saída do `run`.
|
|
450
|
+
const octokit = makeQuietOctokit(token);
|
|
449
451
|
try {
|
|
450
452
|
const res = await octokit.rest.repos.getContent({
|
|
451
453
|
owner, repo, path, ...(ref ? { ref } : {}),
|
|
@@ -456,3 +458,63 @@ export async function getFileContent(token, owner, repo, path, ref) {
|
|
|
456
458
|
throw err;
|
|
457
459
|
}
|
|
458
460
|
}
|
|
461
|
+
|
|
462
|
+
export async function listPullRequestReviews(token, owner, repo, prNumber) {
|
|
463
|
+
const octokit = makeOctokit(token);
|
|
464
|
+
return await octokit.paginate(octokit.rest.pulls.listReviews, {
|
|
465
|
+
owner, repo, pull_number: prNumber, per_page: 100,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
// Variables do Actions — a chave que desarma os workflows.
|
|
471
|
+
//
|
|
472
|
+
// O `if:` de cada job consulta `vars.SPEC_WAVE_EXECUTION`, e é aqui que a CLI
|
|
473
|
+
// escreve esse valor. Exigem permissão de administração no repositório: um 403
|
|
474
|
+
// não é falha do comando, é informação para o usuário (e o `doctor` a repete).
|
|
475
|
+
// ---------------------------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
/** Valor da variável, ou null se ela não existe. */
|
|
478
|
+
export async function getRepoVariable(token, owner, repo, name) {
|
|
479
|
+
const octokit = makeQuietOctokit(token);
|
|
480
|
+
try {
|
|
481
|
+
const res = await octokit.request('GET /repos/{owner}/{repo}/actions/variables/{name}', {
|
|
482
|
+
owner, repo, name,
|
|
483
|
+
});
|
|
484
|
+
return res.data?.value ?? null;
|
|
485
|
+
} catch (err) {
|
|
486
|
+
if (err.status === 404) return null;
|
|
487
|
+
throw err;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Cria ou atualiza a variável. Devolve 'created' | 'updated'. */
|
|
492
|
+
export async function setRepoVariable(token, owner, repo, name, value) {
|
|
493
|
+
const octokit = makeQuietOctokit(token);
|
|
494
|
+
try {
|
|
495
|
+
await octokit.request('POST /repos/{owner}/{repo}/actions/variables', { owner, repo, name, value });
|
|
496
|
+
return 'created';
|
|
497
|
+
} catch (err) {
|
|
498
|
+
// 409 = já existe. Criar-ou-atualizar em duas chamadas é o contrato da API:
|
|
499
|
+
// não há PUT idempotente para variables.
|
|
500
|
+
if (err.status !== 409) throw err;
|
|
501
|
+
await octokit.request('PATCH /repos/{owner}/{repo}/actions/variables/{name}', {
|
|
502
|
+
owner, repo, name, value,
|
|
503
|
+
});
|
|
504
|
+
return 'updated';
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Remove a variável. Ausente já é o estado desejado: 404 é no-op. */
|
|
509
|
+
export async function deleteRepoVariable(token, owner, repo, name) {
|
|
510
|
+
const octokit = makeQuietOctokit(token);
|
|
511
|
+
try {
|
|
512
|
+
await octokit.request('DELETE /repos/{owner}/{repo}/actions/variables/{name}', {
|
|
513
|
+
owner, repo, name,
|
|
514
|
+
});
|
|
515
|
+
return true;
|
|
516
|
+
} catch (err) {
|
|
517
|
+
if (err.status === 404) return false;
|
|
518
|
+
throw err;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
@@ -33,7 +33,8 @@ import {
|
|
|
33
33
|
import { recordUsage } from '../lib/usage-report.mjs';
|
|
34
34
|
import { formatDependencyLine, orderStories, renderOrderComment } from '../lib/dependencies.mjs';
|
|
35
35
|
import { lintLanguage } from '../lib/output-lint.mjs';
|
|
36
|
-
import {
|
|
36
|
+
import { resolveDocDir } from '../lib/doc-paths.mjs';
|
|
37
|
+
import { docBlobUrl } from '../lib/repo-links.mjs';
|
|
37
38
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
38
39
|
import { loadConfig } from '../lib/project-root.mjs';
|
|
39
40
|
import { resolveFlowContext, commitGenerated } from '../lib/flow-run.mjs';
|
|
@@ -222,14 +223,6 @@ export function resolveInheritedMilestone(issue) {
|
|
|
222
223
|
return Number.isInteger(number) && number > 0 ? number : undefined;
|
|
223
224
|
}
|
|
224
225
|
|
|
225
|
-
// Diretório do documento por tipo. Feature usa o mesmo docs/features/<slug> da
|
|
226
|
-
// spec/plan; RFC ganha o seu, já que não passa por spec/plan.
|
|
227
|
-
function resolveDocDir(root, issue, type) {
|
|
228
|
-
const slug = slugify(issue.title);
|
|
229
|
-
const rel = type === 'RFC' ? `docs/rfcs/${slug}` : `docs/features/${slug}`;
|
|
230
|
-
return { slug, rel, dir: path.resolve(root || process.cwd(), rel) };
|
|
231
|
-
}
|
|
232
|
-
|
|
233
226
|
// Lint de idioma sobre títulos+corpos gerados; retorna aviso pronto para
|
|
234
227
|
// anexar ao comentário final ('' se limpo).
|
|
235
228
|
function formatItemsLintWarning(texts) {
|
|
@@ -262,7 +255,7 @@ async function draftDecomposition(ctx) {
|
|
|
262
255
|
const { token, owner, repo, issue, issueNumber, type, labels, usage, root, runMode, docDir, docPath, docRel } = ctx;
|
|
263
256
|
const number = parseInt(issueNumber, 10);
|
|
264
257
|
const kind = DECOMPOSE_TARGETS[type]; // Feature → 'stories'; RFC → 'tasks'
|
|
265
|
-
const blobUrl =
|
|
258
|
+
const blobUrl = docBlobUrl({ owner, repo, pathRel: docRel, mode: runMode, root });
|
|
266
259
|
|
|
267
260
|
const specPath = path.join(docDir, 'spec.md');
|
|
268
261
|
const planPath = path.join(docDir, 'plan.md');
|