@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 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
- - 6 GitHub Actions workflows em `.github/workflows/`
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 `ANTHROPIC_API_KEY`**.
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
- | Anthropic | `ANTHROPIC_API_KEY` | `claude-sonnet-4-6` |
412
- | OpenRouter | `OPENROUTER_API_KEY` | `anthropic/claude-3.7-sonnet` |
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 de uma Feature pelas dependências (topológica)')
267
- .argument('<feature>', 'Número da issue da Feature, ex.: 12 ou #12')
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spec-wave/cli",
3
- "version": "0.21.0",
3
+ "version": "0.24.0",
4
4
  "description": "Setup spec-driven GitHub workflow with Projects v2, labels, issue templates, and AI-powered Actions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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: nos GitHub Actions do spec-wave o
10
- // runner tem `setup-node` e mais nada. Sem o Claude Code instalado, este
11
- // backend NÃO roda os workflows usam o OpenRouter, que é `fetch` puro.
12
- // Este backend serve execução local, onde o Claude Code já existe.
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 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
- const tools = options.tools ?? DEFAULT_TOOLS;
63
- const toolMatcher = `^(${tools.join('|')})$`;
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: { ...process.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
  }
@@ -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. Como o truncamento acima, NÃO é transitório —
50
- // repetir a mesma requisição dá a mesma perambulação, só que mais cara.
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
- // Marcá-lo como transitório custou 55 minutos de Action num caso real: três
53
- // tentativas de 25 turnos (4min 19min 32min) para a mesma falha
54
- // determinística, que devia ter aparecido na primeira.
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
 
@@ -1,8 +1,12 @@
1
- // Registro de backends: transforma um provider num runner concreto.
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 terceiro
5
- // provider significa mexer só aqui.
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
- export const PROVIDERS = ['anthropic', 'openrouter'];
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 provider (função PURA).
33
+ * Runner do backend (função PURA).
24
34
  *
25
- * @param {'anthropic'|'openrouter'} provider
35
+ * @param {'anthropic'|'openrouter'} backend
26
36
  * @returns {(prompt: string, options: object) => Promise<object>}
27
37
  */
28
- export function runnerFor(provider) {
29
- const runner = RUNNERS[provider];
38
+ export function runnerFor(backend) {
39
+ const runner = RUNNERS[backend];
30
40
  if (!runner) {
31
41
  throw new Error(
32
- `Provider desconhecido: "${provider}" (esperado um de: ${PROVIDERS.join(', ')}).`,
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
- * O SDK aceita chave de API OU token OAuth do Claude Code; exigir só a chave
43
- * rejeitaria uma assinatura que funciona.
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'} provider
63
+ * @param {'anthropic'|'openrouter'} backend
46
64
  * @param {object} [env]
47
65
  */
48
- export function assertCredentials(provider, env = process.env) {
49
- if (provider === 'anthropic' && !env.ANTHROPIC_API_KEY && !env.CLAUDE_CODE_OAUTH_TOKEN) {
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 (provider === 'openrouter' && !env.OPENROUTER_API_KEY) {
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? (função PURA)
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**. Os workflows do spec-wave têm `setup-node`, então o
68
- * subprocesso não existe e a falha nativa é um ENOENT sem relação aparente
69
- * com configuração de IA. Falhar aqui, com o que ajustar, custa segundos em vez
70
- * de uma investigação.
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
- * Escape hatch para quem instala o Claude Code no runner:
73
- * `SPEC_WAVE_ALLOW_ANTHROPIC_IN_CI=1`.
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'} provider
139
+ * @param {'anthropic'|'openrouter'} backend
76
140
  * @param {object} [env]
77
141
  */
78
- export function assertBackendRunnable(provider, env = process.env) {
79
- if (provider !== 'anthropic') return;
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 provider `anthropic` não roda no GitHub Actions: ele não chama a API sobe o ' +
84
- 'Claude Code CLI como subprocesso, que não existe no runner ( setup-node).\n\n' +
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. Troque para o OpenRouter no .spec-wave.json: "ai": { "provider": "openrouter", ' +
87
- '"model": "anthropic/claude-opus-4.8" } e adicione o secret OPENROUTER_API_KEY;\n' +
88
- ' 2. ou instale o Claude Code no workflow e defina SPEC_WAVE_ALLOW_ANTHROPIC_IN_CI=1.\n\n' +
89
- 'Localmente o provider `anthropic` funciona normalmente.',
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 provider escolhido.
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 provider.
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(provider);
106
- assertCredentials(provider);
107
- return runnerFor(provider)(prompt, rest);
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) {
@@ -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
- export async function createIssue(token, owner, repo, title, body, labels) {
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({ owner, repo, title, body, labels });
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