@spec-wave/cli 0.15.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 (75) hide show
  1. package/README.md +1 -0
  2. package/bin/spec-wave.mjs +41 -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 +8 -0
  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 +16 -3
  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/prompt-loader.mjs +257 -0
  40. package/src/lib/skill-file.mjs +35 -0
  41. package/src/plugin/.claude-plugin/plugin.json +20 -0
  42. package/src/plugin/README.md +73 -0
  43. package/src/plugin/skills/bug/SKILL.md +60 -0
  44. package/src/plugin/skills/bug/model-prompt.critique.md +48 -0
  45. package/src/plugin/skills/bug/model-prompt.md +74 -0
  46. package/src/plugin/skills/decompose/SKILL.md +111 -0
  47. package/src/plugin/skills/decompose/model-prompt.critique.md +46 -0
  48. package/src/plugin/skills/decompose/model-prompt.feature.md +69 -0
  49. package/src/plugin/skills/decompose/model-prompt.rfc.md +52 -0
  50. package/src/plugin/skills/doctor/SKILL.md +51 -0
  51. package/src/plugin/skills/fix-pr/SKILL.md +130 -0
  52. package/src/plugin/skills/implement/SKILL.md +102 -0
  53. package/src/plugin/skills/info/SKILL.md +40 -0
  54. package/src/plugin/skills/issue/SKILL.md +63 -0
  55. package/src/plugin/skills/move/SKILL.md +52 -0
  56. package/src/plugin/skills/order/SKILL.md +36 -0
  57. package/src/plugin/skills/plan/SKILL.md +53 -0
  58. package/src/plugin/skills/plan/model-prompt.critique.md +44 -0
  59. package/src/plugin/skills/plan/model-prompt.md +59 -0
  60. package/src/plugin/skills/plan/reference/tech-context.md +56 -0
  61. package/src/plugin/skills/ready/SKILL.md +44 -0
  62. package/src/plugin/skills/rfc/SKILL.md +47 -0
  63. package/src/plugin/skills/setup/SKILL.md +67 -0
  64. package/src/plugin/skills/spec/SKILL.md +37 -0
  65. package/src/plugin/skills/spec/model-prompt.md +61 -0
  66. package/src/plugin/skills/story/SKILL.md +49 -0
  67. package/src/plugin/skills/task/SKILL.md +41 -0
  68. package/src/plugin/skills/triage/SKILL.md +52 -0
  69. package/src/plugin/skills/uninstall/SKILL.md +43 -0
  70. package/src/plugin/skills/update/SKILL.md +51 -0
  71. package/src/plugin/skills/workflow/SKILL.md +154 -0
  72. package/src/templates/skill/SKILL.md +54 -4
  73. package/src/templates/workflows/generate-bug.yml +36 -0
  74. package/src/templates/workflows/validate.yml +2 -1
  75. package/src/ui/wizard.mjs +5 -2
@@ -0,0 +1,378 @@
1
+ // Backend OpenRouter: loop de tool-calling contra o /chat/completions
2
+ // compatível com OpenAI. Porte de `agent-cli/src/openrouterAgent.ts`.
3
+ //
4
+ // Não há subprocesso aqui — este processo é dono do loop do modelo E da
5
+ // execução das tools (ver `tools.mjs`), e fala com a rede por `fetch` puro.
6
+ // Não adicione SDK de provider para ele.
7
+ //
8
+ // Três capacidades que o agent-cli não tinha e a spec-wave depende:
9
+ //
10
+ // 1. `maxTokens` — teto de saída por ação (`ai.maxTokensByAction`).
11
+ // 2. Detecção de TRUNCAMENTO — `finish_reason: "length"` vira erro. Um
12
+ // documento cortado no meio de uma frase PASSA na validação de seções e
13
+ // vale menos que documento nenhum, então a geração falha e nada é gravado.
14
+ // 3. Saída ESTRUTURADA — `responseSchema` força uma tool call única com
15
+ // schema, que é como a crítica adversarial garante `severity` no enum.
16
+ // Sem isso, um payload fora do contrato viraria texto livre parseado "na
17
+ // tolerância" — exatamente o bug que rebaixava um finding grave a menor.
18
+
19
+ import {
20
+ createToolContext,
21
+ executeTool,
22
+ toolDefinitionsFor,
23
+ } from './tools.mjs';
24
+ import { withTrace, startObservation, updateSpan, endObservation } from './tracing.mjs';
25
+ import { TruncatedOutputError, isTruncationReason } from './errors.mjs';
26
+
27
+ const DEFAULT_BASE_URL = 'https://openrouter.ai/api/v1';
28
+ const DEFAULT_TOOLS = ['Read', 'Glob', 'Grep'];
29
+ const MAX_TURNS = 25;
30
+ const REQUEST_TIMEOUT_MS = 300_000;
31
+ const MAX_ATTEMPTS = 3;
32
+ const RETRY_STATUS = new Set([408, 409, 429, 500, 502, 503, 504]);
33
+
34
+ /**
35
+ * Não há preset `claude_code` para construir em cima aqui, então as instruções
36
+ * base do agente são nossas. O `systemPromptAppend` (o model-prompt da skill)
37
+ * vem depois disto.
38
+ */
39
+ export const OPENROUTER_SYSTEM_PROMPT = `You are an autonomous CLI coding agent working in the directory {{CWD}}.
40
+
41
+ Use the provided tools to inspect and change files — never guess at file contents you have not read, and never claim to have written a file you did not write with the Write tool.
42
+ Work in small, verifiable steps: locate the relevant files first (Glob/Grep), read them, then act.
43
+ Tool calls are executed by the CLI that hosts you; some paths are blocked by policy, and a refusal comes back as the tool result. If a call is refused, adapt instead of retrying it unchanged.
44
+ When the task is done, stop calling tools and reply with a short summary of what you did.`;
45
+
46
+ function errorMessage(err) {
47
+ return err instanceof Error ? err.message : String(err);
48
+ }
49
+
50
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
51
+
52
+ async function postChatCompletion(body, apiKey, verbose) {
53
+ const baseUrl = process.env.OPENROUTER_BASE_URL ?? DEFAULT_BASE_URL;
54
+ const headers = {
55
+ Authorization: `Bearer ${apiKey}`,
56
+ 'Content-Type': 'application/json',
57
+ 'X-Title': process.env.OPENROUTER_APP_NAME ?? 'spec-wave',
58
+ };
59
+ if (process.env.OPENROUTER_SITE_URL) headers['HTTP-Referer'] = process.env.OPENROUTER_SITE_URL;
60
+
61
+ let lastError;
62
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
63
+ try {
64
+ const response = await fetch(`${baseUrl.replace(/\/$/, '')}/chat/completions`, {
65
+ method: 'POST',
66
+ headers,
67
+ body: JSON.stringify(body),
68
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
69
+ });
70
+ if (!response.ok) {
71
+ const detail = (await response.text().catch(() => '')).slice(0, 500);
72
+ const err = new Error(
73
+ `OpenRouter request failed: ${response.status} ${response.statusText}${detail ? ` — ${detail}` : ''}`,
74
+ );
75
+ if (RETRY_STATUS.has(response.status) && attempt < MAX_ATTEMPTS) {
76
+ lastError = err;
77
+ if (verbose) console.log(`[Agent] ${err.message}; retrying (${attempt}/${MAX_ATTEMPTS - 1})`);
78
+ await sleep(1_000 * 2 ** (attempt - 1));
79
+ continue;
80
+ }
81
+ throw err;
82
+ }
83
+ // Lê como texto e parseia aqui (em vez de `response.json()`) para que um
84
+ // corpo não-JSON entre na mensagem de erro. "Unexpected token <" sem o
85
+ // corpo não diz nada sobre uma página de erro do gateway.
86
+ const raw = await response.text();
87
+ let json;
88
+ try {
89
+ json = JSON.parse(raw);
90
+ } catch {
91
+ throw new Error(`OpenRouter devolveu um corpo não-JSON: ${raw.slice(0, 500)}`);
92
+ }
93
+ // O OpenRouter reporta falha do provider upstream com HTTP 200.
94
+ if (json.error) {
95
+ throw new Error(`OpenRouter returned an error: ${json.error.message ?? JSON.stringify(json.error)}`);
96
+ }
97
+ return json;
98
+ } catch (err) {
99
+ lastError = err;
100
+ const retryable =
101
+ attempt < MAX_ATTEMPTS &&
102
+ (err instanceof TypeError || (err instanceof Error && err.name === 'TimeoutError'));
103
+ if (!retryable) throw err;
104
+ if (verbose) console.log(`[Agent] ${errorMessage(err)}; retrying (${attempt}/${MAX_ATTEMPTS - 1})`);
105
+ await sleep(1_000 * 2 ** (attempt - 1));
106
+ }
107
+ }
108
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
109
+ }
110
+
111
+ /**
112
+ * Corpo EXATO de POST /chat/completions (função PURA — testável sem HTTP).
113
+ *
114
+ * Com `schema`, força a ferramenta via `tool_choice`: o modelo não tem como
115
+ * responder em texto livre, que é o que produzia JSON sujo na crítica. Sem
116
+ * schema, as tools de leitura entram com `tool_choice: "auto"`.
117
+ *
118
+ * @param {object} params
119
+ * @returns {object}
120
+ */
121
+ export function buildChatCompletionBody({
122
+ model, messages, tools = [], schema = null, maxTokens, temperature,
123
+ }) {
124
+ return {
125
+ model,
126
+ messages,
127
+ ...(maxTokens ? { max_tokens: maxTokens } : {}),
128
+ ...(temperature !== undefined ? { temperature } : {}),
129
+ ...(schema
130
+ ? {
131
+ tools: [{
132
+ type: 'function',
133
+ function: {
134
+ name: schema.name,
135
+ description: schema.description,
136
+ parameters: schema.jsonSchema,
137
+ ...(schema.strict ? { strict: true } : {}),
138
+ },
139
+ }],
140
+ tool_choice: { type: 'function', function: { name: schema.name } },
141
+ }
142
+ : tools.length > 0
143
+ ? { tools, tool_choice: 'auto' }
144
+ : {}),
145
+ // Pede ao OpenRouter que inclua a contabilidade de custo em `usage`.
146
+ usage: { include: true },
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Roda o agente no OpenRouter.
152
+ *
153
+ * @param {string} prompt
154
+ * @param {import('./run-types.mjs').AgentRunOptions} options
155
+ * @returns {Promise<import('./run-types.mjs').AgentRunResult>}
156
+ */
157
+ export async function runOpenRouterAgent(prompt, options) {
158
+ const apiKey = process.env.OPENROUTER_API_KEY;
159
+ if (!apiKey) {
160
+ throw new Error('OPENROUTER_API_KEY não definida — adicione-a como secret do repositório.');
161
+ }
162
+
163
+ const schema = options.responseSchema ?? null;
164
+ // Com schema, o modelo não explora nada: a resposta é UMA tool call com o
165
+ // payload. Misturar as duas coisas daria ao modelo a saída de escape de
166
+ // "chamar Read de novo" em vez de fechar o contrato.
167
+ const tools = schema ? [] : (options.tools ?? DEFAULT_TOOLS);
168
+ const toolDefinitions = toolDefinitionsFor(tools);
169
+ const toolContext = createToolContext({
170
+ tools,
171
+ cwd: options.cwd ?? process.cwd(),
172
+ allowedWritePaths: options.allowedWritePaths,
173
+ });
174
+ const maxTurns = schema ? 1 : (options.maxTurns ?? MAX_TURNS);
175
+ const runResult = {
176
+ resultSubtype: null,
177
+ outputText: '',
178
+ costUsd: null,
179
+ numTurns: 0,
180
+ structured: null,
181
+ usage: { inputTokens: 0, outputTokens: 0 },
182
+ };
183
+ const quiet = options.quiet !== false;
184
+
185
+ await withTrace(
186
+ {
187
+ name: options.traceName ?? 'agent-execution',
188
+ sessionId: options.sessionId,
189
+ userId: options.userId,
190
+ tags: ['spec-wave', 'agent', 'openrouter', ...(options.extraTags ?? [])],
191
+ metadata: { model: options.model, provider: 'openrouter' },
192
+ nested: options.nested === true,
193
+ },
194
+ async span => {
195
+ updateSpan(span, { input: prompt });
196
+
197
+ const systemPrompt = [
198
+ schema ? null : OPENROUTER_SYSTEM_PROMPT.replace('{{CWD}}', toolContext.cwd),
199
+ options.systemPromptAppend,
200
+ ]
201
+ .filter(Boolean)
202
+ .join('\n\n');
203
+
204
+ const messages = [
205
+ { role: 'system', content: systemPrompt },
206
+ { role: 'user', content: prompt },
207
+ ];
208
+
209
+ let turns = 0;
210
+ let totalCost = 0;
211
+ let sawCost = false;
212
+ let finalText = '';
213
+ let openGeneration = null;
214
+
215
+ try {
216
+ while (turns < maxTurns) {
217
+ turns++;
218
+ const generation = startObservation(
219
+ span,
220
+ 'generate-response',
221
+ { model: options.model, ...(turns === 1 ? { input: prompt } : {}) },
222
+ 'generation',
223
+ );
224
+ openGeneration = generation;
225
+
226
+ const response = await postChatCompletion(
227
+ buildChatCompletionBody({
228
+ model: options.model,
229
+ messages,
230
+ tools: toolDefinitions,
231
+ schema,
232
+ maxTokens: options.maxTokens,
233
+ temperature: options.temperature,
234
+ }),
235
+ apiKey,
236
+ options.verbose === true,
237
+ );
238
+
239
+ const choice = response.choices?.[0];
240
+ const message = choice?.message;
241
+ const text = message?.content ?? '';
242
+ const toolCalls = message?.tool_calls ?? [];
243
+ const usage = response.usage;
244
+ const cost = typeof usage?.cost === 'number' ? usage.cost : null;
245
+ if (cost !== null) {
246
+ totalCost += cost;
247
+ sawCost = true;
248
+ }
249
+ runResult.usage.inputTokens += usage?.prompt_tokens ?? 0;
250
+ runResult.usage.outputTokens += usage?.completion_tokens ?? 0;
251
+
252
+ if (!quiet && text.trim() !== '') console.log(`\nAssistant: ${text}`);
253
+
254
+ updateSpan(generation, {
255
+ model: response.model ?? options.model,
256
+ output: toolCalls.length > 0
257
+ ? { role: 'assistant', content: text, tool_calls: toolCalls }
258
+ : text,
259
+ usageDetails: {
260
+ input: usage?.prompt_tokens ?? 0,
261
+ output: usage?.completion_tokens ?? 0,
262
+ cache_read_input_tokens: usage?.prompt_tokens_details?.cached_tokens ?? 0,
263
+ },
264
+ ...(cost !== null ? { costDetails: { total: cost } } : {}),
265
+ });
266
+ endObservation(generation);
267
+ openGeneration = null;
268
+
269
+ // TRUNCAMENTO: o corte tem de virar erro ANTES de qualquer coisa ser
270
+ // gravada. Repetir a mesma requisição dá o mesmo corte, então este
271
+ // erro não é transitório e não entra no retry.
272
+ if (isTruncationReason(choice?.finish_reason)) {
273
+ throw new TruncatedOutputError({
274
+ provider: 'openrouter',
275
+ model: response.model ?? options.model,
276
+ maxTokens: options.maxTokens ?? null,
277
+ // `native_finish_reason` preserva o motivo cru do provedor
278
+ // upstream — sem ele o diagnóstico perde de qual camada veio o
279
+ // corte, que é o que distingue teto nosso de teto do provider.
280
+ reason: `${choice.finish_reason}${choice.native_finish_reason ? `/${choice.native_finish_reason}` : ''}`,
281
+ chars: (runResult.outputText + text).length,
282
+ });
283
+ }
284
+
285
+ // Saída estruturada: a tool forçada é o resultado, não um passo.
286
+ if (schema) {
287
+ const call = toolCalls.find(c => c.function?.name === schema.name) ?? toolCalls[0];
288
+ if (!call) {
289
+ // Alguns modelos ainda respondem em texto apesar do tool_choice
290
+ // forçado. TRANSITÓRIO de propósito: repetir a mesma requisição
291
+ // costuma resolver, ao contrário de truncamento (mesmo corte).
292
+ const err = new Error(
293
+ `O modelo não chamou a ferramenta "${schema.name}" ` +
294
+ `(finish_reason: ${choice?.finish_reason ?? 'desconhecido'}).`,
295
+ );
296
+ err.transient = true;
297
+ throw err;
298
+ }
299
+ runResult.structured = JSON.parse(call.function.arguments || '{}');
300
+ runResult.outputText = call.function.arguments ?? '';
301
+ runResult.resultSubtype = 'success';
302
+ break;
303
+ }
304
+
305
+ if (toolCalls.length === 0) {
306
+ finalText = text;
307
+ runResult.outputText = text;
308
+ runResult.resultSubtype = 'success';
309
+ break;
310
+ }
311
+
312
+ messages.push({
313
+ role: 'assistant',
314
+ content: text === '' ? null : text,
315
+ tool_calls: toolCalls,
316
+ });
317
+
318
+ for (const call of toolCalls) {
319
+ let parsedInput;
320
+ let parseError = null;
321
+ try {
322
+ parsedInput = call.function.arguments.trim() === ''
323
+ ? {}
324
+ : JSON.parse(call.function.arguments);
325
+ } catch (err) {
326
+ parseError = `Could not parse tool arguments as JSON: ${errorMessage(err)}`;
327
+ }
328
+
329
+ if (options.verbose) console.log(`\n[Tool] ${call.function.name}(${call.function.arguments})`);
330
+
331
+ const observation = startObservation(
332
+ span,
333
+ `tool-${call.function.name}`,
334
+ { input: parseError === null ? parsedInput : call.function.arguments },
335
+ 'tool',
336
+ );
337
+
338
+ const result = parseError !== null
339
+ ? { output: parseError, isError: true }
340
+ : executeTool(call.function.name, parsedInput, toolContext);
341
+
342
+ updateSpan(observation, {
343
+ output: result.isError ? `[tool failed] ${result.output}` : result.output,
344
+ ...(result.isError ? { level: 'ERROR' } : {}),
345
+ });
346
+ endObservation(observation);
347
+
348
+ messages.push({ role: 'tool', tool_call_id: call.id, content: result.output });
349
+ }
350
+ }
351
+
352
+ if (runResult.resultSubtype === null) {
353
+ runResult.resultSubtype = 'error_max_turns';
354
+ console.warn(`\n[Agent] Run ended without success: error_max_turns (${maxTurns} turns)`);
355
+ }
356
+
357
+ updateSpan(span, {
358
+ output: runResult.resultSubtype === 'success' ? finalText || runResult.outputText : `[${runResult.resultSubtype}]`,
359
+ metadata: {
360
+ total_cost_usd: sawCost ? totalCost : null,
361
+ num_turns: turns,
362
+ provider: 'openrouter',
363
+ permission_denials: toolContext.denials,
364
+ },
365
+ });
366
+ runResult.costUsd = sawCost ? totalCost : null;
367
+ runResult.numTurns = turns;
368
+ } finally {
369
+ endObservation(openGeneration);
370
+ if (runResult.resultSubtype === null) {
371
+ updateSpan(span, { output: '[run ended without a result]' });
372
+ }
373
+ }
374
+ },
375
+ );
376
+
377
+ return runResult;
378
+ }
@@ -0,0 +1,59 @@
1
+ // Contrato de execução que os DOIS backends implementam, para que quem chama
2
+ // nunca ramifique por provider. Porte de `agent-cli/src/runTypes.ts`: lá são
3
+ // `interface`s de TypeScript, aqui typedefs de JSDoc — este pacote não tem
4
+ // etapa de build.
5
+ //
6
+ // Os campos marcados como "extensão spec-wave" não existem no agent-cli; foram
7
+ // acrescentados porque a CLI depende deles (ver `openrouter-agent.mjs`).
8
+
9
+ /**
10
+ * @typedef {object} ResponseSchema
11
+ * Saída estruturada: força UMA tool call com este schema. Extensão spec-wave —
12
+ * é o que garante `severity` dentro do enum na crítica adversarial.
13
+ * @property {string} name nome da ferramenta forçada
14
+ * @property {string} description
15
+ * @property {object} jsonSchema JSON Schema dos parâmetros
16
+ * @property {boolean} [strict] envia `strict: true` (nem todo modelo aceita)
17
+ * @property {(payload: object) => object} [validate] valida/normaliza; lançar
18
+ * aqui dispara o re-pedido de quem chama
19
+ */
20
+
21
+ /**
22
+ * @typedef {object} AgentRunOptions
23
+ * @property {string} model
24
+ * @property {string} sessionId
25
+ * @property {string} userId
26
+ * @property {boolean} [verbose]
27
+ * @property {string[]} [tools] superfície de tools. Default: Read/Glob/Grep —
28
+ * somente leitura, ao contrário do agent-cli (que inclui Write): aqui quem
29
+ * grava documento é o comando, não o modelo
30
+ * @property {string} [systemPromptAppend] anexado ao preset `claude_code`
31
+ * (anthropic) ou ao OPENROUTER_SYSTEM_PROMPT (openrouter)
32
+ * @property {string[]} [allowedWritePaths] quando presente, Write só passa nestes
33
+ * @property {number} [maxTurns] teto de turnos. Default: 25
34
+ * @property {string[]} [extraTags] tags extras da trace
35
+ * @property {string} [traceName] nome da observação raiz. Default: agent-execution
36
+ * @property {boolean} [nested] herda o contexto OTel ambiente em vez de abrir trace
37
+ * @property {boolean} [quiet] silencia o stdout por turno. Default aqui: true
38
+ * (o agent-cli tem stdout como produto; esta CLI tem o documento)
39
+ * @property {string} [cwd] extensão spec-wave: raiz do repositório do usuário
40
+ * @property {number} [maxTokens] extensão spec-wave: teto de tokens de saída
41
+ * @property {number} [temperature] extensão spec-wave
42
+ * @property {ResponseSchema} [responseSchema] extensão spec-wave
43
+ * @property {string} [action] extensão spec-wave: spec|plan|decompose|critique,
44
+ * usado nas mensagens de erro e no relatório de custo
45
+ */
46
+
47
+ /**
48
+ * @typedef {object} AgentRunResult
49
+ * @property {string|null} resultSubtype "success", "error_max_turns", ...
50
+ * @property {string} outputText resposta final; vazia quando o run não produziu
51
+ * texto. Um backend que a deixe vazia quebra quem consome em silêncio
52
+ * @property {number|null} costUsd custo total, ou null se o backend não reportou
53
+ * @property {number} numTurns turnos consumidos
54
+ * @property {object|null} structured extensão spec-wave: payload da tool forçada
55
+ * @property {{inputTokens: number, outputTokens: number}} usage extensão
56
+ * spec-wave: alimenta o relatório de uso comentado na issue
57
+ */
58
+
59
+ export {};
@@ -0,0 +1,54 @@
1
+ // OpenTelemetry + Langfuse. Porte de `agent-cli/src/telemetry.ts`.
2
+ //
3
+ // Por que o NodeSDK importa: o SDK v4 do Langfuse é OTel-nativo — sem
4
+ // `NodeSDK.start()` registrando o tracer provider e o context manager, TODA
5
+ // chamada de observação vira no-op silencioso. E o `shutdown()` é load-bearing:
6
+ // spans exportam em lote, e um processo curto (que é o caso de todo comando
7
+ // desta CLI dentro de um Action) sai antes do último lote sem ele.
8
+ //
9
+ // Diferença em relação ao agent-cli: lá a telemetria é obrigatória. Aqui ela é
10
+ // OPCIONAL — sem as chaves do Langfuse a CLI segue rodando sem tracing, porque
11
+ // gerar a spec.md não pode depender de um serviço de observabilidade.
12
+
13
+ let sdk = null;
14
+
15
+ /** True quando há credenciais do Langfuse no ambiente. */
16
+ export function telemetryConfigured(env = process.env) {
17
+ return Boolean(env.LANGFUSE_PUBLIC_KEY && env.LANGFUSE_SECRET_KEY);
18
+ }
19
+
20
+ /**
21
+ * Inicializa a telemetria, se configurada. Idempotente e best-effort: falha ao
22
+ * subir o SDK NUNCA derruba o comando — no pior caso o run roda sem traces.
23
+ *
24
+ * @returns {Promise<boolean>} true se a telemetria ficou ativa
25
+ */
26
+ export async function initTelemetry({ env = process.env } = {}) {
27
+ if (sdk) return true;
28
+ if (!telemetryConfigured(env)) return false;
29
+ try {
30
+ const [{ NodeSDK }, { LangfuseSpanProcessor }] = await Promise.all([
31
+ import('@opentelemetry/sdk-node'),
32
+ import('@langfuse/otel'),
33
+ ]);
34
+ sdk = new NodeSDK({ spanProcessors: [new LangfuseSpanProcessor()] });
35
+ sdk.start();
36
+ return true;
37
+ } catch (err) {
38
+ console.warn(`[spec-wave] telemetria desativada: ${err.message}`);
39
+ sdk = null;
40
+ return false;
41
+ }
42
+ }
43
+
44
+ /** Faz o flush do último lote de spans. Best-effort, como o init. */
45
+ export async function shutdownTelemetry() {
46
+ if (!sdk) return;
47
+ try {
48
+ await sdk.shutdown();
49
+ } catch (err) {
50
+ console.warn(`[spec-wave] falha ao encerrar a telemetria: ${err.message}`);
51
+ } finally {
52
+ sdk = null;
53
+ }
54
+ }