@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.
- package/README.md +1 -0
- package/bin/spec-wave.mjs +44 -5
- package/package.json +8 -2
- package/src/agent/anthropic-agent.mjs +337 -0
- package/src/agent/errors.mjs +33 -0
- package/src/agent/index.mjs +108 -0
- package/src/agent/openrouter-agent.mjs +378 -0
- package/src/agent/run-types.mjs +59 -0
- package/src/agent/telemetry.mjs +54 -0
- package/src/agent/tools.mjs +452 -0
- package/src/agent/tracing.mjs +106 -0
- package/src/api/github-graphql.mjs +23 -1
- package/src/api/github-rest.mjs +8 -0
- package/src/commands/bug.mjs +8 -0
- package/src/commands/code-review.mjs +45 -4
- package/src/commands/decompose.mjs +22 -72
- package/src/commands/dev-agent.mjs +3 -3
- package/src/commands/doctor.mjs +77 -6
- package/src/commands/generate-bug.mjs +195 -0
- package/src/commands/generate-plan.mjs +19 -44
- package/src/commands/generate-spec.mjs +18 -46
- package/src/commands/implement.mjs +105 -2
- package/src/commands/init.mjs +3 -3
- package/src/commands/install-skill.mjs +72 -16
- package/src/commands/issue.mjs +9 -7
- package/src/commands/move.mjs +11 -1
- package/src/commands/qa.mjs +23 -2
- package/src/commands/refresh.mjs +171 -5
- package/src/commands/triage.mjs +174 -0
- package/src/commands/update.mjs +16 -3
- package/src/commands/validate.mjs +82 -10
- package/src/config.mjs +159 -1
- package/src/lib/bug-context.mjs +160 -0
- package/src/lib/bug-doc.mjs +51 -0
- package/src/lib/bug-triage.mjs +81 -0
- package/src/lib/claude.mjs +71 -254
- package/src/lib/critique.mjs +43 -30
- package/src/lib/flow-run.mjs +145 -0
- package/src/lib/implement-board.mjs +12 -1
- package/src/lib/plugin-skills.mjs +122 -0
- package/src/lib/project-root.mjs +9 -2
- package/src/lib/prompt-loader.mjs +257 -0
- package/src/lib/skill-file.mjs +35 -0
- package/src/plugin/.claude-plugin/plugin.json +20 -0
- package/src/plugin/README.md +73 -0
- package/src/plugin/skills/bug/SKILL.md +60 -0
- package/src/plugin/skills/bug/model-prompt.critique.md +48 -0
- package/src/plugin/skills/bug/model-prompt.md +74 -0
- package/src/plugin/skills/decompose/SKILL.md +117 -0
- package/src/plugin/skills/decompose/model-prompt.critique.md +46 -0
- package/src/plugin/skills/decompose/model-prompt.feature.md +69 -0
- package/src/plugin/skills/decompose/model-prompt.rfc.md +52 -0
- package/src/plugin/skills/doctor/SKILL.md +51 -0
- package/src/plugin/skills/fix-pr/SKILL.md +130 -0
- package/src/plugin/skills/implement/SKILL.md +102 -0
- package/src/plugin/skills/info/SKILL.md +40 -0
- package/src/plugin/skills/issue/SKILL.md +63 -0
- package/src/plugin/skills/move/SKILL.md +52 -0
- package/src/plugin/skills/order/SKILL.md +36 -0
- package/src/plugin/skills/plan/SKILL.md +58 -0
- package/src/plugin/skills/plan/model-prompt.critique.md +44 -0
- package/src/plugin/skills/plan/model-prompt.md +59 -0
- package/src/plugin/skills/plan/reference/tech-context.md +56 -0
- package/src/plugin/skills/ready/SKILL.md +44 -0
- package/src/plugin/skills/rfc/SKILL.md +47 -0
- package/src/plugin/skills/setup/SKILL.md +67 -0
- package/src/plugin/skills/spec/SKILL.md +55 -0
- package/src/plugin/skills/spec/model-prompt.md +61 -0
- package/src/plugin/skills/story/SKILL.md +49 -0
- package/src/plugin/skills/task/SKILL.md +41 -0
- package/src/plugin/skills/triage/SKILL.md +52 -0
- package/src/plugin/skills/uninstall/SKILL.md +43 -0
- package/src/plugin/skills/update/SKILL.md +51 -0
- package/src/plugin/skills/workflow/SKILL.md +158 -0
- package/src/templates/skill/SKILL.md +54 -4
- package/src/templates/workflows/generate-bug.yml +36 -0
- package/src/templates/workflows/validate.yml +2 -1
- 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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
+
}
|