@spec-wave/cli 0.23.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spec-wave/cli",
3
- "version": "0.23.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:
@@ -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
- 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,
@@ -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: { ...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),
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
  }
@@ -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
  }
@@ -14,7 +14,7 @@ import {
14
14
  } from '../api/auth.mjs';
15
15
  import { getProjectSnapshot, listSubIssues } from '../api/github-graphql.mjs';
16
16
  import {
17
- CONFIG_FILE, WORKFLOW_FILES, getProvider, DEFAULT_PROVIDER, STATUS_OPTIONS,
17
+ CONFIG_FILE, WORKFLOW_FILES, getProvider, DEFAULT_PROVIDER, AI_PROVIDERS, STATUS_OPTIONS,
18
18
  RETIRED_STAGES, ALL_LABELS, allLabelsFor, LABEL_NEEDS_HUMAN, MODEL_LABEL_PREFIX,
19
19
  DEFAULT_MAX_CRITIQUE_ATTEMPTS, STAGE_TRACKS, AI_ACTIONS, recommendedModelAliases,
20
20
  modelLabels,
@@ -679,8 +679,9 @@ async function checkAi(ctx) {
679
679
  `${Object.entries(aliases).map(([a, m]) => `${a}=${m}`).join(', ')}.`
680
680
  );
681
681
  // Slug da OpenRouter tem "/" (anthropic/claude-…); id da Anthropic, não.
682
+ // O critério é o BACKEND: `claude-oauth` fala com a mesma API do `anthropic`.
682
683
  const wrongShape = Object.entries(aliases).filter(([, m]) => (
683
- provider.value === 'openrouter' ? !String(m).includes('/') : String(m).includes('/')
684
+ provider.backend === 'openrouter' ? !String(m).includes('/') : String(m).includes('/')
684
685
  ));
685
686
  if (wrongShape.length > 0) {
686
687
  status = 'warn';
@@ -700,7 +701,7 @@ async function checkAi(ctx) {
700
701
 
701
702
  // Saída estruturada da crítica: sem structured output confiável, a validação
702
703
  // do schema queima os retries antes de falhar.
703
- if (provider.value === 'openrouter' && !fileAi.models?.critique) {
704
+ if (provider.backend === 'openrouter' && !fileAi.models?.critique) {
704
705
  status = 'warn';
705
706
  notes.push(
706
707
  `Saída estruturada da crítica: o provider é openrouter e \`ai.models.critique\` não está ` +
@@ -708,21 +709,27 @@ async function checkAi(ctx) {
708
709
  'e a crítica falha na primeira execução. Aponte `ai.models.critique` para um modelo com ' +
709
710
  'saída estruturada.'
710
711
  );
712
+ } else if (provider.backend === 'anthropic') {
713
+ // No backend anthropic quem garante o schema é o próprio Claude Code
714
+ // (`outputFormat: json_schema`), repetindo o turno até casar — não há
715
+ // tool call forçado nem `strict` de endpoint para reportar.
716
+ notes.push(
717
+ `Saída estruturada da crítica: json_schema do Claude Code (${provider.value}) ` +
718
+ '— o CLI repete o turno até a resposta casar com o schema.'
719
+ );
711
720
  } else {
712
721
  notes.push(
713
722
  `Saída estruturada da crítica: tool call forçado (${provider.value}) ` +
714
723
  `· strict=${supportsStrictSchema(critiqueModel) ? 'sim' : 'não'} neste modelo.`
715
724
  );
716
725
  }
717
- // O backend anthropic não chama a API — sobe o Claude Code CLI como
718
- // subprocesso, que não existe no runner dos workflows (só setup-node).
719
- if (provider.value === 'anthropic') {
720
- problems.push(
721
- 'O provider `anthropic` NÃO roda nos GitHub Actions: ele sobe o Claude Code CLI como ' +
722
- 'subprocesso, ausente no runner. As Actions de spec/plan/decompose vão falhar. ' +
723
- 'Troque para `"provider": "openrouter"` no .spec-wave.json (e adicione o secret ' +
724
- 'OPENROUTER_API_KEY), ou instale o Claude Code no workflow e defina ' +
725
- 'SPEC_WAVE_ALLOW_ANTHROPIC_IN_CI=1. Localmente o anthropic funciona.'
726
+ // O backend anthropic sobe o Claude Code CLI como subprocesso — e o binário
727
+ // vem embarcado no SDK, então o runner não precisa de instalação extra.
728
+ if (provider.backend === 'anthropic') {
729
+ notes.push(
730
+ `Backend anthropic (${provider.value}): o Claude Code sobe como subprocesso a partir do ` +
731
+ 'binário embarcado no @anthropic-ai/claude-agent-sdk nada a instalar no runner além do ' +
732
+ 'próprio CLI. falha se o pacote for instalado com `--omit=optional`.'
726
733
  );
727
734
  }
728
735
 
@@ -955,10 +962,19 @@ async function checkWorkflows(ctx) {
955
962
  // comportamento de pipelines já em andamento.
956
963
  const unpinned = [];
957
964
  const otherVersion = [];
965
+ // O secret do provider escolhido chega ao runner? A YAML é que decide: um
966
+ // repo configurado com um provider cujo secret o workflow não encaminha morre
967
+ // com "Faltam credenciais" no meio do fluxo, e o único conserto é `update`.
968
+ const provider = getProvider(ctx.cfg?.ai?.provider) || getProvider(DEFAULT_PROVIDER);
969
+ const missingSecret = [];
958
970
  for (const file of WORKFLOW_FILES) {
959
971
  const content = readFileSync(path.join(dir, file), 'utf-8');
960
972
  if (/@spec-wave\/cli@latest/.test(content)) unpinned.push(file);
961
973
  else if (!content.includes(`@spec-wave/cli@${CLI_VERSION}`)) otherVersion.push(file);
974
+ // Identifica os workflows de IA pelo que eles próprios declaram, em vez de
975
+ // manter uma segunda lista para sair de sincronia com WORKFLOW_FILES.
976
+ const usesAi = AI_PROVIDERS.some(pr => content.includes(`secrets.${pr.secret}`));
977
+ if (usesAi && !content.includes(`secrets.${provider.secret}`)) missingSecret.push(file);
962
978
  }
963
979
  const notes = [`Os ${WORKFLOW_FILES.length} workflows do spec-wave estão presentes.`];
964
980
  let status = 'ok';
@@ -976,6 +992,16 @@ async function checkWorkflows(ctx) {
976
992
  'para fazer o bump explícito.'
977
993
  );
978
994
  }
995
+ if (missingSecret.length > 0) {
996
+ status = 'fail';
997
+ notes.push(
998
+ `Não encaminham \`${provider.secret}\` (secret do provider ${provider.value}): ` +
999
+ `${missingSecret.join(', ')}. Esses workflows vão falhar com "Faltam credenciais" — ` +
1000
+ 'rode `npx @spec-wave/cli@latest update`.'
1001
+ );
1002
+ } else {
1003
+ notes.push(`Todos encaminham \`${provider.secret}\` (provider ${provider.value}).`);
1004
+ }
979
1005
  return { name, status, detail: notes.join('\n') };
980
1006
  }
981
1007
 
@@ -220,6 +220,11 @@ export async function init(options) {
220
220
  ? ` 1. Commite o ${CONFIG_FILE} quando quiser (git add ${CONFIG_FILE} && git commit)\n`
221
221
  : '') +
222
222
  ` ${configWritten ? '2' : '1'}. Adicione ${providerMeta.secret} como secret no repositório (provider: ${providerMeta.label})\n` +
223
+ // O valor do token da assinatura não é copiável de lugar nenhum: só existe
224
+ // depois de rodar o comando que o gera.
225
+ (providerMeta.secret === 'CLAUDE_CODE_OAUTH_TOKEN'
226
+ ? ` ${chalk.dim('Gere o valor com: claude setup-token')}\n`
227
+ : '') +
223
228
  ` ${configWritten ? '3' : '2'}. Configure o board view para agrupar por "Etapa"\n` +
224
229
  ` ${configWritten ? '4' : '3'}. Crie uma Feature com o prefixo [FEATURE] no título\n` +
225
230
  ` ${configWritten ? '5' : '4'}. Use a skill spec-wave para guiar o fluxo\n\n` +
package/src/config.mjs CHANGED
@@ -11,17 +11,36 @@ export const PORTAL_URL = 'https://spec-wave.astratech.net.br';
11
11
  // O provider e o modelo escolhidos no `init` são persistidos em .spec-wave.json
12
12
  // (bloco `ai`) e lidos em runtime por src/lib/claude.mjs. Cada provider declara
13
13
  // o secret do GitHub Actions de onde a chave é lida.
14
+ //
15
+ // `value` é o que o usuário escolhe; `backend` é QUEM executa (src/agent/).
16
+ // Os dois não coincidem: `anthropic` e `claude-oauth` rodam no mesmo backend e
17
+ // diferem só na credencial — chave de API por token vs. token da assinatura.
18
+ // Separar os campos evita que cada consumidor tenha que decorar essa relação.
14
19
  export const AI_PROVIDERS = [
15
20
  {
16
21
  value: 'anthropic',
22
+ backend: 'anthropic',
17
23
  label: 'Anthropic (API direta)',
18
24
  hint: 'Usa o secret ANTHROPIC_API_KEY',
19
25
  secret: 'ANTHROPIC_API_KEY',
20
26
  defaultModel: 'claude-sonnet-4-6',
21
27
  modelHint: 'ex.: claude-sonnet-4-6, claude-opus-4-1',
22
28
  },
29
+ {
30
+ // Mesmo backend do `anthropic`, credencial diferente: o token da assinatura
31
+ // Claude Pro/Max (`claude setup-token`), com escopo só de inferência. O
32
+ // consumo sai do limite do plano, não de crédito por token.
33
+ value: 'claude-oauth',
34
+ backend: 'anthropic',
35
+ label: 'Claude (assinatura Pro/Max via OAuth)',
36
+ hint: 'Usa o secret CLAUDE_CODE_OAUTH_TOKEN — gere com `claude setup-token`',
37
+ secret: 'CLAUDE_CODE_OAUTH_TOKEN',
38
+ defaultModel: 'claude-sonnet-4-6',
39
+ modelHint: 'ex.: claude-sonnet-4-6, claude-opus-4-1',
40
+ },
23
41
  {
24
42
  value: 'openrouter',
43
+ backend: 'openrouter',
25
44
  label: 'OpenRouter (multi-modelo)',
26
45
  hint: 'Usa o secret OPENROUTER_API_KEY',
27
46
  secret: 'OPENROUTER_API_KEY',
@@ -49,10 +68,12 @@ export const MODEL_LABEL_PREFIX = 'spec-wave:model:';
49
68
  // `spec-wave:model:<apelido>` cubra "roda de novo num modelo mais forte" sem
50
69
  // obrigar cada projeto a inventar a própria nomenclatura.
51
70
  //
52
- // O mapa é POR PROVIDER porque o formato do id não é o mesmo: a OpenRouter usa
53
- // slug com "/" (anthropic/claude-opus-5) e a API da Anthropic usa o id nu
54
- // (claude-opus-5). Sugerir o formato errado geraria exatamente o aviso de
55
- // "apelido com formato incompatível" que o doctor emite na linha seguinte.
71
+ // O mapa é POR BACKEND, não por provider, porque o que ele distingue é o
72
+ // formato do id: a OpenRouter usa slug com "/" (anthropic/claude-opus-5) e a
73
+ // Anthropic usa o id nu (claude-opus-5). Sugerir o formato errado geraria
74
+ // exatamente o aviso de "apelido com formato incompatível" que o doctor emite
75
+ // na linha seguinte. `anthropic` e `claude-oauth` compartilham o mesmo mapa
76
+ // porque compartilham o backend.
56
77
  export const RECOMMENDED_MODEL_ALIASES = {
57
78
  openrouter: {
58
79
  opus: 'anthropic/claude-opus-5',
@@ -78,7 +99,17 @@ export const RECOMMENDED_MODEL_ALIASES = {
78
99
  * @returns {Record<string,string>}
79
100
  */
80
101
  export function recommendedModelAliases(provider) {
81
- return RECOMMENDED_MODEL_ALIASES[provider] || RECOMMENDED_MODEL_ALIASES[DEFAULT_PROVIDER];
102
+ return RECOMMENDED_MODEL_ALIASES[providerBackend(provider)];
103
+ }
104
+
105
+ /**
106
+ * Backend que executa um provider (função PURA).
107
+ *
108
+ * @param {string} [value] valor de `ai.provider`
109
+ * @returns {'anthropic'|'openrouter'}
110
+ */
111
+ export function providerBackend(value) {
112
+ return (getProvider(value) || getProvider(DEFAULT_PROVIDER)).backend;
82
113
  }
83
114
 
84
115
  // Quantas críticas seguidas podem reprovar a mesma issue antes de exigir revisão
@@ -109,6 +109,9 @@ export function resolveAiConfig({ env = {}, fileAi = {}, action, labels = [], mo
109
109
 
110
110
  return {
111
111
  provider: meta.value,
112
+ // Quem executa. `anthropic` e `claude-oauth` caem no mesmo backend e
113
+ // diferem só na credencial — src/agent/ nunca vê essa distinção.
114
+ backend: meta.backend,
112
115
  model: rawModel.trim(),
113
116
  modelSource,
114
117
  labelAlias,
@@ -359,7 +362,7 @@ export async function generateDocument(systemPrompt, userContent, opts = {}) {
359
362
  // Extraída do generateDocument para o generateStructured usar a mesma contagem.
360
363
  function recordUsageEntry(opts, ai, { inputTokens, outputTokens, cost }) {
361
364
  if (!Array.isArray(opts.usage)) return;
362
- const finalCost = cost === null && ai.provider === 'anthropic'
365
+ const finalCost = cost === null && ai.backend === 'anthropic'
363
366
  ? computeCost({ model: ai.model, inputTokens, outputTokens, pricing: ai.pricing })
364
367
  : cost;
365
368
  opts.usage.push({
@@ -507,7 +510,7 @@ async function generateViaEngine(
507
510
  systemPrompt, userContent, ai, { temperature, maxTokens, maxTurns, schema, strict, action, tools },
508
511
  ) {
509
512
  const result = await runAgent(userContent, {
510
- provider: ai.provider,
513
+ provider: ai.backend,
511
514
  model: ai.model,
512
515
  systemPromptAppend: systemPrompt,
513
516
  // Sessão/usuário alimentam o agrupamento das traces no Langfuse. Sem
@@ -536,7 +539,14 @@ async function generateViaEngine(
536
539
  if (result.structured === null || result.structured === undefined) {
537
540
  const err = new Error(
538
541
  `O modelo ${ai.model} não devolveu a saída estruturada "${schema.name}" ` +
539
- `(subtype=${result.resultSubtype}).`
542
+ `(subtype=${result.resultSubtype}).` +
543
+ (result.resultSubtype === 'error_max_structured_output_retries'
544
+ // Subtype só do backend anthropic: o CLI repetiu o turno e a saída
545
+ // nunca bateu com o schema. Repetir tende a resolver; se insistir, o
546
+ // modelo é que não dá conta do contrato.
547
+ ? ' O Claude Code esgotou as tentativas de casar a resposta com o schema —' +
548
+ ' se repetir, aponte `ai.models.critique` para um modelo mais forte.'
549
+ : '')
540
550
  );
541
551
  err.transient = true; // repetir a mesma requisição costuma resolver
542
552
  throw err;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "spec-wave",
3
3
  "displayName": "Spec Wave",
4
- "version": "0.23.0",
4
+ "version": "0.24.0",
5
5
  "description": "Fluxo spec-driven no GitHub (RFC-001): Projects v2, labels de gatilho, spec/plan gerados por Action, decomposição em duas etapas e implementação orientada a Stories/Tasks.",
6
6
  "author": {
7
7
  "name": "Astratech",
@@ -43,7 +43,7 @@ npx @spec-wave/cli@latest doctor
43
43
  - escopo faltando → o usuário roda `!gh auth refresh --scopes project,repo,workflow` (interativo, ele executa)
44
44
  - `.spec-wave.json` dessincronizado → `npx @spec-wave/cli@latest refresh --config`
45
45
  - workflows/labels divergentes → skill **update**
46
- - secret de IA ausente → Settings → Secrets → Actions (`ANTHROPIC_API_KEY` ou `OPENROUTER_API_KEY`)
46
+ - secret de IA ausente → Settings → Secrets → Actions (`ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN` ou `OPENROUTER_API_KEY`, conforme o provider)
47
47
  - `specKit.command` ausente → configure antes de usar a skill **implement**
48
48
  3. Trate `!` como "não deu para verificar", não como falha.
49
49
  4. Se tudo passar e o problema original persistir, aí sim investigue a superfície específica (Action, issue, board).
@@ -58,7 +58,7 @@ Você dirige o `init` **com flags**. Nunca rode `npx @spec-wave/cli@latest init`
58
58
 
59
59
  8. **Adapte o `tech_context.yml`** — o scaffold vem com dados de exemplo e a qualidade do `plan.md` depende dele. Ofereça ajustá-lo agora; o passo a passo está na skill **plan** (`reference/tech-context.md`).
60
60
 
61
- 9. **Secret de IA:** instrua a adicionar em Settings → Secrets → Actions a chave do provider escolhido: `ANTHROPIC_API_KEY` (Anthropic) ou `OPENROUTER_API_KEY` (OpenRouter).
61
+ 9. **Secret de IA:** instrua a adicionar em Settings → Secrets → Actions a credencial do provider escolhido: `ANTHROPIC_API_KEY` (Anthropic), `CLAUDE_CODE_OAUTH_TOKEN` (assinatura Claude Pro/Max — gere com `claude setup-token`) ou `OPENROUTER_API_KEY` (OpenRouter).
62
62
 
63
63
  10. **Próximo passo:** criar a primeira Feature — skill **issue**.
64
64
 
@@ -459,7 +459,7 @@ Configura o spec-wave no repositório. Você dirige o `init` com flags — **nun
459
459
  Use `--skip-project` / `--skip-labels` / `--skip-files` **apenas** para re-rodar uma fase específica que falhou antes.
460
460
  7. O `init` cria o Project, as labels, os workflows, um **scaffold de `.github/config/tech_context.yml`** (só se ainda não existir) e grava `.spec-wave.json`. Oriente o usuário a fazer `git pull` para trazer os arquivos ao checkout local.
461
461
  8. **Adapte o `tech_context.yml`**: o scaffold vem com dados de exemplo. Ofereça ajustá-lo à stack real do repo seguindo a seção **Tech Context** (perto do comando `/spec-wave plan`) — isso melhora muito a qualidade do `plan.md`.
462
- 9. Instrua o usuário a adicionar a chave de IA como secret no repositório (Settings → Secrets → Actions): `ANTHROPIC_API_KEY` (Anthropic) ou `OPENROUTER_API_KEY` (OpenRouter), conforme o provider escolhido no `init`.
462
+ 9. Instrua o usuário a adicionar a credencial de IA como secret no repositório (Settings → Secrets → Actions): `ANTHROPIC_API_KEY` (Anthropic), `CLAUDE_CODE_OAUTH_TOKEN` (assinatura Claude Pro/Max — gere com `claude setup-token`) ou `OPENROUTER_API_KEY` (OpenRouter), conforme o provider escolhido no `init`.
463
463
 
464
464
  ---
465
465
 
@@ -51,5 +51,6 @@ jobs:
51
51
  env:
52
52
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
53
53
  ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
54
+ CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
54
55
  OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
55
56
  GITHUB_REPOSITORY: ${{ github.repository }}
@@ -76,5 +76,6 @@ jobs:
76
76
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
77
77
  PROJECT_TOKEN: ${{ secrets.GH_PROJECT_TOKEN }}
78
78
  ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
79
+ CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
79
80
  OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
80
81
  GITHUB_REPOSITORY: ${{ github.repository }}
@@ -56,5 +56,6 @@ jobs:
56
56
  env:
57
57
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
58
58
  ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
59
+ CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
59
60
  OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
60
61
  GITHUB_REPOSITORY: ${{ github.repository }}
@@ -64,5 +64,6 @@ jobs:
64
64
  env:
65
65
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
66
66
  ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
67
+ CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
67
68
  OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
68
69
  GITHUB_REPOSITORY: ${{ github.repository }}
@@ -64,5 +64,6 @@ jobs:
64
64
  env:
65
65
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
66
66
  ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
67
+ CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
67
68
  OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
68
69
  GITHUB_REPOSITORY: ${{ github.repository }}