@spec-wave/cli 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +1 -0
  2. package/bin/spec-wave.mjs +44 -5
  3. package/package.json +8 -2
  4. package/src/agent/anthropic-agent.mjs +337 -0
  5. package/src/agent/errors.mjs +33 -0
  6. package/src/agent/index.mjs +108 -0
  7. package/src/agent/openrouter-agent.mjs +378 -0
  8. package/src/agent/run-types.mjs +59 -0
  9. package/src/agent/telemetry.mjs +54 -0
  10. package/src/agent/tools.mjs +452 -0
  11. package/src/agent/tracing.mjs +106 -0
  12. package/src/api/github-graphql.mjs +23 -1
  13. package/src/api/github-rest.mjs +8 -0
  14. package/src/commands/bug.mjs +8 -0
  15. package/src/commands/code-review.mjs +45 -4
  16. package/src/commands/decompose.mjs +22 -72
  17. package/src/commands/dev-agent.mjs +3 -3
  18. package/src/commands/doctor.mjs +77 -6
  19. package/src/commands/generate-bug.mjs +195 -0
  20. package/src/commands/generate-plan.mjs +19 -44
  21. package/src/commands/generate-spec.mjs +18 -46
  22. package/src/commands/implement.mjs +105 -2
  23. package/src/commands/init.mjs +3 -3
  24. package/src/commands/install-skill.mjs +72 -16
  25. package/src/commands/issue.mjs +9 -7
  26. package/src/commands/move.mjs +11 -1
  27. package/src/commands/qa.mjs +23 -2
  28. package/src/commands/refresh.mjs +171 -5
  29. package/src/commands/triage.mjs +174 -0
  30. package/src/commands/update.mjs +16 -3
  31. package/src/commands/validate.mjs +82 -10
  32. package/src/config.mjs +159 -1
  33. package/src/lib/bug-context.mjs +160 -0
  34. package/src/lib/bug-doc.mjs +51 -0
  35. package/src/lib/bug-triage.mjs +81 -0
  36. package/src/lib/claude.mjs +71 -254
  37. package/src/lib/critique.mjs +43 -30
  38. package/src/lib/flow-run.mjs +145 -0
  39. package/src/lib/implement-board.mjs +12 -1
  40. package/src/lib/plugin-skills.mjs +122 -0
  41. package/src/lib/project-root.mjs +9 -2
  42. package/src/lib/prompt-loader.mjs +257 -0
  43. package/src/lib/skill-file.mjs +35 -0
  44. package/src/plugin/.claude-plugin/plugin.json +20 -0
  45. package/src/plugin/README.md +73 -0
  46. package/src/plugin/skills/bug/SKILL.md +60 -0
  47. package/src/plugin/skills/bug/model-prompt.critique.md +48 -0
  48. package/src/plugin/skills/bug/model-prompt.md +74 -0
  49. package/src/plugin/skills/decompose/SKILL.md +117 -0
  50. package/src/plugin/skills/decompose/model-prompt.critique.md +46 -0
  51. package/src/plugin/skills/decompose/model-prompt.feature.md +69 -0
  52. package/src/plugin/skills/decompose/model-prompt.rfc.md +52 -0
  53. package/src/plugin/skills/doctor/SKILL.md +51 -0
  54. package/src/plugin/skills/fix-pr/SKILL.md +130 -0
  55. package/src/plugin/skills/implement/SKILL.md +102 -0
  56. package/src/plugin/skills/info/SKILL.md +40 -0
  57. package/src/plugin/skills/issue/SKILL.md +63 -0
  58. package/src/plugin/skills/move/SKILL.md +52 -0
  59. package/src/plugin/skills/order/SKILL.md +36 -0
  60. package/src/plugin/skills/plan/SKILL.md +58 -0
  61. package/src/plugin/skills/plan/model-prompt.critique.md +44 -0
  62. package/src/plugin/skills/plan/model-prompt.md +59 -0
  63. package/src/plugin/skills/plan/reference/tech-context.md +56 -0
  64. package/src/plugin/skills/ready/SKILL.md +44 -0
  65. package/src/plugin/skills/rfc/SKILL.md +47 -0
  66. package/src/plugin/skills/setup/SKILL.md +67 -0
  67. package/src/plugin/skills/spec/SKILL.md +55 -0
  68. package/src/plugin/skills/spec/model-prompt.md +61 -0
  69. package/src/plugin/skills/story/SKILL.md +49 -0
  70. package/src/plugin/skills/task/SKILL.md +41 -0
  71. package/src/plugin/skills/triage/SKILL.md +52 -0
  72. package/src/plugin/skills/uninstall/SKILL.md +43 -0
  73. package/src/plugin/skills/update/SKILL.md +51 -0
  74. package/src/plugin/skills/workflow/SKILL.md +158 -0
  75. package/src/templates/skill/SKILL.md +54 -4
  76. package/src/templates/workflows/generate-bug.yml +36 -0
  77. package/src/templates/workflows/validate.yml +2 -1
  78. package/src/ui/wizard.mjs +5 -2
package/README.md CHANGED
@@ -22,6 +22,7 @@ O resultado é um board Kanban no GitHub Projects v2 que avança automaticamente
22
22
 
23
23
  ```
24
24
  📥 Backlog
25
+ → 🐞 Triagem ← só Bug reportado (RFC-004)
25
26
  → 🎯 Priorizado
26
27
  → 📋 Spec ← label spec-wave:spec → Action gera spec.md
27
28
  → 📋 Plan ← label spec-wave:plan → Action gera plan.md
package/bin/spec-wave.mjs CHANGED
@@ -42,6 +42,9 @@ program
42
42
  .command('refresh')
43
43
  .description('Atualiza o .spec-wave.json local com os dados atuais do GitHub Project')
44
44
  .option('--config', 'Re-consulta o Project e reescreve o .spec-wave.json')
45
+ .option('--stages', 'Acrescenta ao campo "Etapa" as colunas canônicas que faltam (nunca remove)')
46
+ .option('--dry-run', 'Com --stages: mostra o que seria enviado e não escreve nada')
47
+ .option('--yes', 'Com --stages: não pede confirmação')
45
48
  .action(async (options) => {
46
49
  const { refresh } = await import('../src/commands/refresh.mjs');
47
50
  await refresh(options).catch(err => { console.error(err.message); process.exit(1); });
@@ -86,6 +89,33 @@ program
86
89
  await feature(options).catch(err => { console.error(err.message); process.exit(1); });
87
90
  });
88
91
 
92
+ program
93
+ .command('bug')
94
+ .description('Cria um Bug (atalho de `issue --type bug`) — nasce em 🐞 Triagem, ou ✅ Ready se P0')
95
+ .requiredOption('--title <title>', 'Título (sem o prefixo [BUG])')
96
+ .option('--parent <n>', 'Feature ou Story afetada (cria como sub-issue dela)')
97
+ .option('--body <text>', 'Descrição: passos, esperado e obtido')
98
+ .option('--priority <p>', 'Severidade: P0, P1, P2 ou P3')
99
+ .option('--area <area>', 'Área: Frontend, Backend, Mobile, Infra, DevOps ou Data')
100
+ .action(async (options) => {
101
+ const { bug } = await import('../src/commands/bug.mjs');
102
+ await bug(options).catch(err => { console.error(err.message); process.exit(1); });
103
+ });
104
+
105
+ program
106
+ .command('triage')
107
+ .description('Tria um Bug: accept (→ ✅ Ready), reject (fecha) ou duplicate (fecha)')
108
+ .argument('<action>', 'accept | reject | duplicate')
109
+ .argument('<issue>', 'Número da issue do Bug')
110
+ .option('--reason <texto>', 'Motivo da rejeição (obrigatório em reject)')
111
+ .option('--of <n>', 'Número da issue original (obrigatório em duplicate)')
112
+ .option('--severity <p>', 'Reclassifica a severidade ao aceitar: P0–P3')
113
+ .action(async (action, issue, options) => {
114
+ const { triage } = await import('../src/commands/triage.mjs');
115
+ await triage({ action, issue, ...options })
116
+ .catch(err => { console.error(err.message); process.exit(1); });
117
+ });
118
+
89
119
  program
90
120
  .command('update')
91
121
  .description('Detecta o que está desatualizado (skill, .spec-wave.json, workflows/labels do repo) e atualiza só o que mudou')
@@ -105,7 +135,7 @@ program
105
135
 
106
136
  program
107
137
  .command('install-skill')
108
- .description('Instala a skill spec-wave no(s) agente(s) detectado(s): Claude Code, Cursor, opencode, Cline, Kilo, Antigravity, AGENTS.md')
138
+ .description('Instala a skill spec-wave no(s) agente(s) detectado(s): Claude Code, Codex, Cursor, opencode, Cline, Kilo, Antigravity, AGENTS.md')
109
139
  .option('--agent <names>', 'Agente(s) alvo, separados por vírgula (pula a detecção)')
110
140
  .option('--all', 'Instala em todos os agentes detectados')
111
141
  .option('--global', 'Instala no escopo do usuário (padrão: projeto)')
@@ -133,7 +163,7 @@ program
133
163
 
134
164
  program
135
165
  .command('generate-plan')
136
- .description('Gera plan.md para uma Feature (usado pelo GitHub Action)')
166
+ .description('Gera plan.md para uma Feature roda no GitHub Action ou localmente')
137
167
  .requiredOption('--issue-number <n>', 'Número da issue no GitHub')
138
168
  .action(async (options) => {
139
169
  const { generatePlan } = await import('../src/commands/generate-plan.mjs');
@@ -142,16 +172,25 @@ program
142
172
 
143
173
  program
144
174
  .command('generate-spec')
145
- .description('Gera spec.md para uma Feature (usado pelo GitHub Action)')
175
+ .description('Gera spec.md para uma Feature roda no GitHub Action ou localmente')
146
176
  .requiredOption('--issue-number <n>', 'Número da issue no GitHub')
147
177
  .action(async (options) => {
148
178
  const { generateSpec } = await import('../src/commands/generate-spec.mjs');
149
179
  await generateSpec(options).catch(err => { console.error(err.message); process.exit(1); });
150
180
  });
151
181
 
182
+ program
183
+ .command('generate-bug')
184
+ .description('Gera bug.md para um Bug (usado pelo GitHub Action)')
185
+ .requiredOption('--issue-number <n>', 'Número da issue no GitHub')
186
+ .action(async (options) => {
187
+ const { generateBug } = await import('../src/commands/generate-bug.mjs');
188
+ await generateBug(options).catch(err => { console.error(err.message); process.exit(1); });
189
+ });
190
+
152
191
  program
153
192
  .command('validate')
154
- .description('Valida spec.md e plan.md de uma Feature (usado pelo GitHub Action)')
193
+ .description('Valida os documentos de uma issue: spec.md+plan.md de Feature, bug.md de Bug (usado pelo GitHub Action)')
155
194
  .requiredOption('--issue-number <n>', 'Número da issue no GitHub')
156
195
  .action(async (options) => {
157
196
  const { validate } = await import('../src/commands/validate.mjs');
@@ -160,7 +199,7 @@ program
160
199
 
161
200
  program
162
201
  .command('decompose')
163
- .description('Gera o rascunho da decomposição em decomposition.md; com --apply, cria as Stories/Tasks a partir do rascunho revisado (usado pelo GitHub Action)')
202
+ .description('Gera o rascunho da decomposição em decomposition.md; com --apply, cria as Stories/Tasks a partir do rascunho revisado roda no Action ou localmente')
164
203
  .requiredOption('--issue-number <n>', 'Número da issue no GitHub')
165
204
  .option('--apply', 'Aplica o decomposition.md já revisado: cria as issues (sem esta flag, apenas gera/critica o rascunho)')
166
205
  .action(async (options) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spec-wave/cli",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
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": {
@@ -20,12 +20,18 @@
20
20
  "node": ">=20"
21
21
  },
22
22
  "dependencies": {
23
- "@anthropic-ai/sdk": "^0.36.3",
23
+ "@anthropic-ai/claude-agent-sdk": "^0.3.220",
24
24
  "@clack/prompts": "^0.9.1",
25
+ "@langfuse/otel": "^5.9.1",
26
+ "@langfuse/tracing": "^5.9.1",
25
27
  "@octokit/graphql": "^9.0.1",
26
28
  "@octokit/rest": "^22.0.0",
29
+ "@opentelemetry/sdk-node": "^0.221.0",
27
30
  "chalk": "^5.4.1",
28
31
  "commander": "^13.1.0",
29
32
  "js-yaml": "^4.1.0"
33
+ },
34
+ "overrides": {
35
+ "@hono/node-server": "^2.0.5"
30
36
  }
31
37
  }
@@ -0,0 +1,337 @@
1
+ // Backend anthropic: `query()` do Claude Agent SDK. Porte de
2
+ // `agent-cli/src/agent.ts`.
3
+ //
4
+ // FATO CENTRAL: este processo NUNCA chama a API da Anthropic. `query()` sobe o
5
+ // **Claude Code CLI como processo filho**, e é ele que é dono do loop do modelo
6
+ // e da execução das tools. Por isso não há dependência direta de
7
+ // `@anthropic-ai/sdk` aqui — não reintroduza uma.
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 lá — os workflows usam o OpenRouter, que é `fetch` puro.
12
+ // Este backend serve execução local, onde o Claude Code já existe.
13
+ //
14
+ // Tudo que o processo pai aprende chega por dois canais, e os dois viram
15
+ // observações do Langfuse manualmente:
16
+ //
17
+ // 1. HOOKS — `PreToolUse` abre uma observação `tool-<nome>` (indexada por
18
+ // `tool_use_id`), `PostToolUse` a fecha com a saída, `PostToolUseFailure` a
19
+ // fecha como erro. Uma varredura no `finally` fecha o que ficou órfão.
20
+ // 2. STREAM DE MENSAGENS — mensagens do assistente acumulam em UMA generation
21
+ // por id de mensagem da API (um turno pode emitir várias com o mesmo id:
22
+ // tool calls paralelas, recuperação de thinking, fallback de recusa). Um id
23
+ // novo encerra todas as anteriores.
24
+
25
+ import path from 'node:path';
26
+ import { withTrace, startObservation, updateSpan, endObservation } from './tracing.mjs';
27
+ import { ENV_FILE_PATTERN } from './tools.mjs';
28
+ import { TruncatedOutputError } from './errors.mjs';
29
+
30
+ const DEFAULT_TOOLS = ['Read', 'Glob', 'Grep'];
31
+ const MAX_TURNS = 25;
32
+
33
+ /** Base de comparação da allowlist: absoluto resolvido, minúsculo no win32. */
34
+ function normalizeWritePath(p) {
35
+ const resolved = path.resolve(p);
36
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
37
+ }
38
+
39
+ /**
40
+ * Roda o agente pelo Claude Agent SDK.
41
+ *
42
+ * @param {string} prompt
43
+ * @param {import('./run-types.mjs').AgentRunOptions} options
44
+ * @returns {Promise<import('./run-types.mjs').AgentRunResult>}
45
+ */
46
+ export async function runAnthropicAgent(prompt, options) {
47
+ if (options.responseSchema) {
48
+ // O subprocesso não expõe tool_choice forçado, então não há como garantir
49
+ // a tool call única que a saída estruturada exige. Falhar aqui é melhor do
50
+ // que devolver texto livre que o chamador tentaria parsear "na tolerância"
51
+ // — foi exatamente esse caminho que rebaixava um finding grave a menor.
52
+ throw new Error(
53
+ 'Saída estruturada não é suportada no backend anthropic (o subprocesso do ' +
54
+ 'Claude Code não expõe tool_choice forçado). Use o provider openrouter para ' +
55
+ 'ações com schema — hoje, a crítica adversarial.',
56
+ );
57
+ }
58
+
59
+ const { query } = await import('@anthropic-ai/claude-agent-sdk');
60
+
61
+ const tools = options.tools ?? DEFAULT_TOOLS;
62
+ const toolMatcher = `^(${tools.join('|')})$`;
63
+ const allowedWritePaths = options.allowedWritePaths?.map(normalizeWritePath) ?? null;
64
+ const runResult = {
65
+ resultSubtype: null,
66
+ outputText: '',
67
+ costUsd: null,
68
+ numTurns: 0,
69
+ structured: null,
70
+ usage: { inputTokens: 0, outputTokens: 0 },
71
+ };
72
+ const quiet = options.quiet !== false;
73
+
74
+ await withTrace(
75
+ {
76
+ name: options.traceName ?? 'agent-execution',
77
+ sessionId: options.sessionId,
78
+ userId: options.userId,
79
+ tags: ['spec-wave', 'agent', 'anthropic', ...(options.extraTags ?? [])],
80
+ metadata: { model: options.model, provider: 'anthropic' },
81
+ nested: options.nested === true,
82
+ },
83
+ async span => {
84
+ updateSpan(span, { input: prompt });
85
+
86
+ const toolObservations = new Map();
87
+ const generations = new Map();
88
+ let printedAnyText = false;
89
+ let sawResult = false;
90
+ let isFirstTurn = true;
91
+ let lastStopReason = null;
92
+
93
+ const queryOptions = {
94
+ model: options.model,
95
+ maxTurns: options.maxTurns ?? MAX_TURNS,
96
+ // `tools` restringe o que o modelo sequer VÊ; `allowedTools` é só a
97
+ // lista de auto-aprovação sobre essa superfície. Sem `tools`, as ~31
98
+ // tools do Claude Code aparecem e são apenas aprovadas/negadas por
99
+ // chamada.
100
+ tools,
101
+ allowedTools: tools,
102
+ ...(options.systemPromptAppend !== undefined
103
+ ? {
104
+ systemPrompt: {
105
+ type: 'preset',
106
+ preset: 'claude_code',
107
+ append: options.systemPromptAppend,
108
+ },
109
+ }
110
+ : {}),
111
+ ...(options.maxTokens ? { maxTokens: options.maxTokens } : {}),
112
+ permissionMode: 'dontAsk',
113
+ cwd: options.cwd ?? process.cwd(),
114
+ ...(options.allowedWritePaths?.length
115
+ ? {
116
+ additionalDirectories: [
117
+ ...new Set(options.allowedWritePaths.map(p => path.dirname(path.resolve(p)))),
118
+ ],
119
+ }
120
+ : {}),
121
+ env: { ...process.env },
122
+ // Desliga o carregamento de settings do filesystem (~/.claude,
123
+ // .claude/settings.json) para o subprocesso não herdar hooks ou
124
+ // permissões de fora deste `queryOptions`.
125
+ settingSources: [],
126
+ hooks: {
127
+ PreToolUse: [{
128
+ matcher: toolMatcher,
129
+ hooks: [async (input, toolUseID) => {
130
+ const id = toolUseID ?? input.tool_use_id;
131
+ const filePath = input.tool_input?.file_path;
132
+
133
+ if (typeof filePath === 'string' && ENV_FILE_PATTERN.test(filePath)) {
134
+ return {
135
+ hookSpecificOutput: {
136
+ hookEventName: 'PreToolUse',
137
+ permissionDecision: 'deny',
138
+ permissionDecisionReason: 'Access to .env files is blocked',
139
+ },
140
+ };
141
+ }
142
+
143
+ if (
144
+ allowedWritePaths !== null &&
145
+ input.tool_name === 'Write' &&
146
+ (typeof filePath !== 'string' || !allowedWritePaths.includes(normalizeWritePath(filePath)))
147
+ ) {
148
+ return {
149
+ hookSpecificOutput: {
150
+ hookEventName: 'PreToolUse',
151
+ permissionDecision: 'deny',
152
+ permissionDecisionReason: `Writes are allowed only to: ${(options.allowedWritePaths ?? []).join(', ')}`,
153
+ },
154
+ };
155
+ }
156
+
157
+ if (options.verbose) {
158
+ console.log(`\n[Tool] ${input.tool_name}(${JSON.stringify(input.tool_input)})`);
159
+ }
160
+ if (id) {
161
+ toolObservations.set(
162
+ id,
163
+ startObservation(span, `tool-${input.tool_name}`, { input: input.tool_input }, 'tool'),
164
+ );
165
+ }
166
+ return {};
167
+ }],
168
+ }],
169
+ PostToolUse: [{
170
+ matcher: toolMatcher,
171
+ hooks: [async (input, toolUseID) => {
172
+ const id = toolUseID ?? input.tool_use_id;
173
+ const obs = id ? toolObservations.get(id) : undefined;
174
+ if (obs) {
175
+ updateSpan(obs, { output: input.tool_response });
176
+ endObservation(obs);
177
+ toolObservations.delete(id);
178
+ }
179
+ return {};
180
+ }],
181
+ }],
182
+ PostToolUseFailure: [{
183
+ matcher: toolMatcher,
184
+ hooks: [async (input, toolUseID) => {
185
+ const id = toolUseID ?? input.tool_use_id;
186
+ const obs = id ? toolObservations.get(id) : undefined;
187
+ if (obs) {
188
+ updateSpan(obs, { output: `[tool failed] ${input.error}`, level: 'ERROR' });
189
+ endObservation(obs);
190
+ toolObservations.delete(id);
191
+ }
192
+ return {};
193
+ }],
194
+ }],
195
+ },
196
+ };
197
+
198
+ try {
199
+ for await (const message of query({ prompt, options: queryOptions })) {
200
+ if (message.type === 'system') {
201
+ if (message.subtype === 'init' && options.verbose) {
202
+ console.log(`[Agent] Claude Code session ${message.session_id} started`);
203
+ }
204
+ continue;
205
+ }
206
+
207
+ if (message.type === 'assistant') {
208
+ const apiMessage = message.message;
209
+ lastStopReason = apiMessage.stop_reason ?? lastStopReason;
210
+ for (const block of apiMessage.content) {
211
+ if (block.type === 'text' && block.text.trim() !== '') {
212
+ if (!quiet) console.log(`\nClaude: ${block.text}`);
213
+ printedAnyText = true;
214
+ }
215
+ }
216
+
217
+ const usage = apiMessage.usage;
218
+ runResult.usage.inputTokens += usage?.input_tokens ?? 0;
219
+ runResult.usage.outputTokens += usage?.output_tokens ?? 0;
220
+
221
+ let entry = generations.get(apiMessage.id);
222
+ if (!entry) {
223
+ // Id novo = turno novo. Encerra as generations abertas agora, em
224
+ // vez de esperar a varredura do finally, para que a duração de
225
+ // cada uma reflita o próprio turno e não o run inteiro.
226
+ for (const prev of generations.values()) endObservation(prev.gen);
227
+ generations.clear();
228
+
229
+ const gen = startObservation(
230
+ span,
231
+ 'generate-response',
232
+ { model: apiMessage.model, ...(isFirstTurn ? { input: prompt } : {}) },
233
+ 'generation',
234
+ );
235
+ isFirstTurn = false;
236
+ entry = { gen, textParts: [], toolCalls: [] };
237
+ generations.set(apiMessage.id, entry);
238
+ }
239
+
240
+ const text = apiMessage.content
241
+ .filter(block => block.type === 'text')
242
+ .map(block => block.text)
243
+ .join('\n');
244
+ if (text !== '') entry.textParts.push(text);
245
+ for (const block of apiMessage.content) {
246
+ if (block.type === 'tool_use') {
247
+ entry.toolCalls.push({ id: block.id, name: block.name, input: block.input });
248
+ }
249
+ }
250
+
251
+ const joinedText = entry.textParts.join('\n');
252
+ updateSpan(entry.gen, {
253
+ output: entry.toolCalls.length > 0
254
+ ? {
255
+ role: 'assistant',
256
+ content: joinedText,
257
+ tool_calls: entry.toolCalls.map(call => ({
258
+ id: call.id,
259
+ type: 'function',
260
+ function: { name: call.name, arguments: JSON.stringify(call.input) },
261
+ })),
262
+ }
263
+ : joinedText,
264
+ usageDetails: {
265
+ input: usage?.input_tokens ?? 0,
266
+ output: usage?.output_tokens ?? 0,
267
+ cache_read_input_tokens: usage?.cache_read_input_tokens ?? 0,
268
+ cache_creation_input_tokens: usage?.cache_creation_input_tokens ?? 0,
269
+ },
270
+ });
271
+ if (options.verbose) {
272
+ console.log(`[Usage] in=${usage?.input_tokens ?? 0} out=${usage?.output_tokens ?? 0}`);
273
+ }
274
+ continue;
275
+ }
276
+
277
+ if (message.type === 'result') {
278
+ sawResult = true;
279
+ runResult.resultSubtype = message.subtype;
280
+ runResult.costUsd = message.total_cost_usd;
281
+ runResult.numTurns = message.num_turns;
282
+ let output;
283
+ if (message.subtype === 'success') {
284
+ output = message.result;
285
+ runResult.outputText = message.result;
286
+ if (!quiet && !printedAnyText && message.result.trim() !== '') {
287
+ console.log(`\nClaude: ${message.result}`);
288
+ }
289
+ } else {
290
+ output = `[${message.subtype}]`;
291
+ console.warn(`\n[Agent] Run ended without success: ${message.subtype}`);
292
+ }
293
+ updateSpan(span, {
294
+ output,
295
+ metadata: {
296
+ total_cost_usd: message.total_cost_usd,
297
+ num_turns: message.num_turns,
298
+ claude_code_session_id: message.session_id,
299
+ permission_denials: message.permission_denials?.length ?? 0,
300
+ },
301
+ });
302
+ if (!quiet) {
303
+ console.log(`\n[Cost] $${message.total_cost_usd.toFixed(4)} across ${message.num_turns} turn(s)`);
304
+ }
305
+ }
306
+ }
307
+ } finally {
308
+ for (const obs of toolObservations.values()) {
309
+ updateSpan(obs, { output: '[no PostToolUse received]' });
310
+ endObservation(obs);
311
+ }
312
+ toolObservations.clear();
313
+ for (const entry of generations.values()) endObservation(entry.gen);
314
+ generations.clear();
315
+ if (!sawResult) {
316
+ updateSpan(span, { output: '[stream ended without a result message]' });
317
+ }
318
+ }
319
+
320
+ // O corte só é detectável DEPOIS do stream: o `stop_reason` chega na
321
+ // mensagem do assistente, e o resultado vem em seguida. Verificar aqui
322
+ // ainda cumpre a garantia que importa — o erro sobe antes de qualquer
323
+ // gravação, porque quem grava é o comando, não este backend.
324
+ if (lastStopReason === 'max_tokens') {
325
+ throw new TruncatedOutputError({
326
+ provider: 'anthropic',
327
+ model: options.model,
328
+ maxTokens: options.maxTokens ?? null,
329
+ reason: lastStopReason,
330
+ chars: runResult.outputText.length,
331
+ });
332
+ }
333
+ },
334
+ );
335
+
336
+ return runResult;
337
+ }
@@ -0,0 +1,33 @@
1
+ // Erros e sinais compartilhados entre o motor do agente e o adaptador
2
+ // `lib/claude.mjs`.
3
+ //
4
+ // Vieram de `lib/claude.mjs`; foram movidos para cá quando os backends
5
+ // portados do agent-cli passaram a precisar deles, para não haver duas
6
+ // definições de "saída truncada" divergindo em silêncio.
7
+
8
+ // A OpenRouter sinaliza corte em `choices[0].finish_reason`, a Anthropic em
9
+ // `message.stop_reason`. Ignorar esse campo é o que produzia um documento
10
+ // cortado no meio de uma frase, commitado como se estivesse completo.
11
+ const TRUNCATION_REASONS = new Set(['length', 'max_tokens']);
12
+
13
+ /** Motivo de parada indica saída cortada no teto de tokens? (função PURA) */
14
+ export function isTruncationReason(reason) {
15
+ return TRUNCATION_REASONS.has(reason);
16
+ }
17
+
18
+ // Repetir a MESMA requisição depois de truncar dá o mesmo corte — só sobe o
19
+ // custo. Por isso NÃO é marcado como transitório: o erro sobe, o Action falha
20
+ // visível, destrava a label e comenta na issue o que ajustar.
21
+ export class TruncatedOutputError extends Error {
22
+ constructor({ provider, model, maxTokens, reason, chars }) {
23
+ super(
24
+ `Saída truncada pelo teto de tokens (${provider} · ${model} · max_tokens=${maxTokens} · ` +
25
+ `motivo=${reason}). Foram gerados ~${chars} caracteres antes do corte. ` +
26
+ 'Aumente `ai.maxTokens` (ou `ai.maxTokensByAction`) no .spec-wave.json, ou reduza o ' +
27
+ 'tamanho da issue de origem. O documento NÃO foi gravado — um documento cortado ' +
28
+ 'passaria na validação de seções e valeria menos que nenhum.'
29
+ );
30
+ this.name = 'TruncatedOutputError';
31
+ this.truncated = true;
32
+ }
33
+ }
@@ -0,0 +1,108 @@
1
+ // Registro de backends: transforma um provider num runner concreto.
2
+ // Porte de `agent-cli/src/workflow/backends.ts` + `src/lib.ts`.
3
+ //
4
+ // É o ÚNICO módulo que sabe que os dois backends existem, então um terceiro
5
+ // provider significa mexer só aqui.
6
+
7
+ import { runAnthropicAgent } from './anthropic-agent.mjs';
8
+ import { runOpenRouterAgent } from './openrouter-agent.mjs';
9
+
10
+ export { TruncatedOutputError, isTruncationReason } from './errors.mjs';
11
+ export { ENV_FILE_PATTERN, createToolContext, executeTool, toolDefinitionsFor, KNOWN_TOOL_NAMES } from './tools.mjs';
12
+ export { initTelemetry, shutdownTelemetry, telemetryConfigured } from './telemetry.mjs';
13
+ export { runAnthropicAgent, runOpenRouterAgent };
14
+
15
+ export const PROVIDERS = ['anthropic', 'openrouter'];
16
+
17
+ const RUNNERS = {
18
+ anthropic: runAnthropicAgent,
19
+ openrouter: runOpenRouterAgent,
20
+ };
21
+
22
+ /**
23
+ * Runner do provider (função PURA).
24
+ *
25
+ * @param {'anthropic'|'openrouter'} provider
26
+ * @returns {(prompt: string, options: object) => Promise<object>}
27
+ */
28
+ export function runnerFor(provider) {
29
+ const runner = RUNNERS[provider];
30
+ if (!runner) {
31
+ throw new Error(
32
+ `Provider desconhecido: "${provider}" (esperado um de: ${PROVIDERS.join(', ')}).`,
33
+ );
34
+ }
35
+ return runner;
36
+ }
37
+
38
+ /**
39
+ * Confere credenciais ANTES de qualquer chamada de modelo — um fluxo que morre
40
+ * no meio por falta de chave já gastou dinheiro de verdade.
41
+ *
42
+ * O SDK aceita chave de API OU token OAuth do Claude Code; exigir só a chave
43
+ * rejeitaria uma assinatura que funciona.
44
+ *
45
+ * @param {'anthropic'|'openrouter'} provider
46
+ * @param {object} [env]
47
+ */
48
+ export function assertCredentials(provider, env = process.env) {
49
+ if (provider === 'anthropic' && !env.ANTHROPIC_API_KEY && !env.CLAUDE_CODE_OAUTH_TOKEN) {
50
+ throw new Error(
51
+ 'Faltam credenciais: defina ANTHROPIC_API_KEY ou CLAUDE_CODE_OAUTH_TOKEN ' +
52
+ '(secret do repositório, em Settings → Secrets → Actions).',
53
+ );
54
+ }
55
+ if (provider === 'openrouter' && !env.OPENROUTER_API_KEY) {
56
+ throw new Error(
57
+ 'Faltam credenciais: defina OPENROUTER_API_KEY ' +
58
+ '(secret do repositório, em Settings → Secrets → Actions).',
59
+ );
60
+ }
61
+ }
62
+
63
+ /**
64
+ * O backend pode rodar NESTE ambiente? (função PURA)
65
+ *
66
+ * O backend anthropic não chama a API: `query()` sobe o **Claude Code CLI como
67
+ * processo filho**. Os workflows do spec-wave só têm `setup-node`, então lá 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.
71
+ *
72
+ * Escape hatch para quem instala o Claude Code no runner:
73
+ * `SPEC_WAVE_ALLOW_ANTHROPIC_IN_CI=1`.
74
+ *
75
+ * @param {'anthropic'|'openrouter'} provider
76
+ * @param {object} [env]
77
+ */
78
+ export function assertBackendRunnable(provider, env = process.env) {
79
+ if (provider !== 'anthropic') return;
80
+ if (env.GITHUB_ACTIONS !== 'true') return;
81
+ if (env.SPEC_WAVE_ALLOW_ANTHROPIC_IN_CI) return;
82
+ 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 (só há setup-node).\n\n' +
85
+ '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.',
90
+ );
91
+ }
92
+
93
+ /**
94
+ * Executa uma ação de IA no provider escolhido.
95
+ *
96
+ * É 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.
98
+ *
99
+ * @param {string} prompt conteúdo do usuário
100
+ * @param {import('./run-types.mjs').AgentRunOptions & {provider: string}} options
101
+ * @returns {Promise<import('./run-types.mjs').AgentRunResult>}
102
+ */
103
+ export async function runAgent(prompt, options) {
104
+ const { provider, ...rest } = options;
105
+ assertBackendRunnable(provider);
106
+ assertCredentials(provider);
107
+ return runnerFor(provider)(prompt, rest);
108
+ }