@spec-wave/cli 0.14.0 → 0.16.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.
Files changed (76) hide show
  1. package/README.md +1 -0
  2. package/bin/spec-wave.mjs +44 -2
  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-rest.mjs +206 -2
  13. package/src/commands/bug.mjs +8 -0
  14. package/src/commands/code-review.mjs +45 -4
  15. package/src/commands/decompose.mjs +11 -49
  16. package/src/commands/dev-agent.mjs +3 -3
  17. package/src/commands/doctor.mjs +77 -6
  18. package/src/commands/generate-bug.mjs +195 -0
  19. package/src/commands/generate-plan.mjs +6 -20
  20. package/src/commands/generate-spec.mjs +6 -22
  21. package/src/commands/implement.mjs +105 -2
  22. package/src/commands/init.mjs +3 -3
  23. package/src/commands/install-skill.mjs +72 -16
  24. package/src/commands/issue.mjs +9 -7
  25. package/src/commands/move.mjs +11 -1
  26. package/src/commands/qa.mjs +23 -2
  27. package/src/commands/refresh.mjs +145 -5
  28. package/src/commands/triage.mjs +174 -0
  29. package/src/commands/update.mjs +352 -62
  30. package/src/commands/validate.mjs +82 -10
  31. package/src/config.mjs +159 -1
  32. package/src/lib/bug-context.mjs +160 -0
  33. package/src/lib/bug-doc.mjs +51 -0
  34. package/src/lib/bug-triage.mjs +81 -0
  35. package/src/lib/claude.mjs +71 -254
  36. package/src/lib/critique.mjs +43 -30
  37. package/src/lib/implement-board.mjs +12 -1
  38. package/src/lib/plugin-skills.mjs +122 -0
  39. package/src/lib/pr-branch.mjs +267 -0
  40. package/src/lib/prompt-loader.mjs +257 -0
  41. package/src/lib/skill-file.mjs +35 -0
  42. package/src/plugin/.claude-plugin/plugin.json +20 -0
  43. package/src/plugin/README.md +73 -0
  44. package/src/plugin/skills/bug/SKILL.md +60 -0
  45. package/src/plugin/skills/bug/model-prompt.critique.md +48 -0
  46. package/src/plugin/skills/bug/model-prompt.md +74 -0
  47. package/src/plugin/skills/decompose/SKILL.md +111 -0
  48. package/src/plugin/skills/decompose/model-prompt.critique.md +46 -0
  49. package/src/plugin/skills/decompose/model-prompt.feature.md +69 -0
  50. package/src/plugin/skills/decompose/model-prompt.rfc.md +52 -0
  51. package/src/plugin/skills/doctor/SKILL.md +51 -0
  52. package/src/plugin/skills/fix-pr/SKILL.md +130 -0
  53. package/src/plugin/skills/implement/SKILL.md +102 -0
  54. package/src/plugin/skills/info/SKILL.md +40 -0
  55. package/src/plugin/skills/issue/SKILL.md +63 -0
  56. package/src/plugin/skills/move/SKILL.md +52 -0
  57. package/src/plugin/skills/order/SKILL.md +36 -0
  58. package/src/plugin/skills/plan/SKILL.md +53 -0
  59. package/src/plugin/skills/plan/model-prompt.critique.md +44 -0
  60. package/src/plugin/skills/plan/model-prompt.md +59 -0
  61. package/src/plugin/skills/plan/reference/tech-context.md +56 -0
  62. package/src/plugin/skills/ready/SKILL.md +44 -0
  63. package/src/plugin/skills/rfc/SKILL.md +47 -0
  64. package/src/plugin/skills/setup/SKILL.md +67 -0
  65. package/src/plugin/skills/spec/SKILL.md +37 -0
  66. package/src/plugin/skills/spec/model-prompt.md +61 -0
  67. package/src/plugin/skills/story/SKILL.md +49 -0
  68. package/src/plugin/skills/task/SKILL.md +41 -0
  69. package/src/plugin/skills/triage/SKILL.md +52 -0
  70. package/src/plugin/skills/uninstall/SKILL.md +43 -0
  71. package/src/plugin/skills/update/SKILL.md +51 -0
  72. package/src/plugin/skills/workflow/SKILL.md +154 -0
  73. package/src/templates/skill/SKILL.md +69 -7
  74. package/src/templates/workflows/generate-bug.yml +36 -0
  75. package/src/templates/workflows/validate.yml +2 -1
  76. 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')
@@ -93,6 +123,9 @@ program
93
123
  .option('--skip-skill', 'Não verifica/atualiza a skill instalada')
94
124
  .option('--skip-config', 'Não verifica/atualiza o .spec-wave.json local')
95
125
  .option('--skip-repo', 'Não verifica/atualiza workflows e labels do repo')
126
+ .option('--branch [nome]', 'Envia os arquivos do repo como Pull Request numa branch, em um único commit (sem valor: spec-wave/update-v<versão>)')
127
+ .option('--config-in-pr', 'Força incluir o .spec-wave.json no Pull Request')
128
+ .option('--no-config-in-pr', 'Força manter o .spec-wave.json fora do Pull Request')
96
129
  .option('--dry-run', 'Mostra o que seria atualizado sem alterar nada')
97
130
  .option('--yes', 'Aplica sem pedir confirmação')
98
131
  .action(async (options) => {
@@ -102,7 +135,7 @@ program
102
135
 
103
136
  program
104
137
  .command('install-skill')
105
- .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')
106
139
  .option('--agent <names>', 'Agente(s) alvo, separados por vírgula (pula a detecção)')
107
140
  .option('--all', 'Instala em todos os agentes detectados')
108
141
  .option('--global', 'Instala no escopo do usuário (padrão: projeto)')
@@ -146,9 +179,18 @@ program
146
179
  await generateSpec(options).catch(err => { console.error(err.message); process.exit(1); });
147
180
  });
148
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
+
149
191
  program
150
192
  .command('validate')
151
- .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)')
152
194
  .requiredOption('--issue-number <n>', 'Número da issue no GitHub')
153
195
  .action(async (options) => {
154
196
  const { validate } = await import('../src/commands/validate.mjs');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spec-wave/cli",
3
- "version": "0.14.0",
3
+ "version": "0.16.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": {
@@ -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
+ }