@spec-wave/cli 0.7.0 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spec-wave/cli",
3
- "version": "0.7.0",
3
+ "version": "0.7.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": {
@@ -150,8 +150,10 @@ export async function removeLabel(token, owner, repo, issueNumber, labelName) {
150
150
  }
151
151
  }
152
152
 
153
- // Lista todos os comentários de uma issue/PR: [{ author, body, createdAt }].
154
- // Paginado — traz o histórico completo (usado pela crítica adversarial).
153
+ // Lista todos os comentários de uma issue/PR: [{ id, author, body, createdAt }].
154
+ // Paginado — traz o histórico completo (usado pela crítica adversarial e pelo
155
+ // comentário acumulado de uso de IA). `id` é o database id do comentário,
156
+ // exigido pelo updateComment.
155
157
  export async function listIssueComments(token, owner, repo, issueNumber) {
156
158
  const octokit = makeOctokit(token);
157
159
  const comments = await octokit.paginate(octokit.rest.issues.listComments, {
@@ -161,12 +163,25 @@ export async function listIssueComments(token, owner, repo, issueNumber) {
161
163
  per_page: 100,
162
164
  });
163
165
  return comments.map(c => ({
166
+ id: c.id,
164
167
  author: c.user?.login || '',
165
168
  body: c.body || '',
166
169
  createdAt: c.created_at,
167
170
  }));
168
171
  }
169
172
 
173
+ // Atualiza o corpo de um comentário existente. `commentId` é o database id
174
+ // retornado por listIssueComments.
175
+ export async function updateComment(token, owner, repo, commentId, body) {
176
+ const octokit = makeOctokit(token);
177
+ await octokit.rest.issues.updateComment({
178
+ owner,
179
+ repo,
180
+ comment_id: commentId,
181
+ body,
182
+ });
183
+ }
184
+
170
185
  // Marca uma issue como bloqueada por outra (relação nativa do GitHub).
171
186
  // `blockingIssueId` é o DATABASE id da issue bloqueadora (não o number nem o
172
187
  // node id — ver createIssue). Erros propagam: o chamador usa como fallback.
@@ -5,6 +5,7 @@ import { getIssue, createIssue, removeLabel, addLabel, commentOnIssue, addBlocke
5
5
  import { addSubIssue, addProjectItem, setItemSingleSelect, getSingleSelectField, listSubIssues } from '../api/github-graphql.mjs';
6
6
  import { generateDocument } from '../lib/claude.mjs';
7
7
  import { runCritique } from '../lib/critique.mjs';
8
+ import { recordUsage } from '../lib/usage-report.mjs';
8
9
  import { formatDependencyLine } from '../lib/dependencies.mjs';
9
10
  import { lintLanguage } from '../lib/output-lint.mjs';
10
11
  import { slugify } from '../lib/slugify.mjs';
@@ -148,7 +149,7 @@ Regras:
148
149
 
149
150
  // Decompõe uma Feature em Stories (+ Tasks), cada uma vinculada como sub-issue.
150
151
  async function decomposeFeature(ctx) {
151
- const { token, projectToken, owner, repo, issue, issueNumber, project, statusField } = ctx;
152
+ const { token, projectToken, owner, repo, issue, issueNumber, project, statusField, usage } = ctx;
152
153
  const slug = slugify(issue.title);
153
154
  const featureDir = `docs/features/${slug}`;
154
155
 
@@ -167,7 +168,7 @@ async function decomposeFeature(ctx) {
167
168
  `\n## plan.md\n${planContent}`,
168
169
  ].join('\n');
169
170
 
170
- const decomposition = parseJson(await generateDocument(FEATURE_SYSTEM_PROMPT, userContent));
171
+ const decomposition = parseJson(await generateDocument(FEATURE_SYSTEM_PROMPT, userContent, { action: 'decompose', usage }));
171
172
 
172
173
  // Crítica adversarial ANTES de criar qualquer issue: stories que contradizem
173
174
  // a spec/plan não devem virar trabalho. Crítica indisponível → só avisa.
@@ -178,6 +179,7 @@ async function decomposeFeature(ctx) {
178
179
  spec: existsSync(`${featureDir}/spec.md`) ? specContent : null,
179
180
  plan: existsSync(`${featureDir}/plan.md`) ? planContent : null,
180
181
  stories: decomposition.stories,
182
+ usage,
181
183
  });
182
184
  } catch (err) {
183
185
  console.warn(`Crítica adversarial indisponível: ${err.message}`);
@@ -293,7 +295,7 @@ async function decomposeFeature(ctx) {
293
295
  // Decompõe um RFC diretamente em Tasks (sem Stories), cada uma vinculada como
294
296
  // sub-issue do RFC.
295
297
  async function decomposeRFC(ctx) {
296
- const { token, projectToken, owner, repo, issue, issueNumber, project, statusField } = ctx;
298
+ const { token, projectToken, owner, repo, issue, issueNumber, project, statusField, usage } = ctx;
297
299
  console.log(`Decompondo RFC: ${issue.title}`);
298
300
 
299
301
  const userContent = [
@@ -302,7 +304,7 @@ async function decomposeRFC(ctx) {
302
304
  `\n## Descrição\n${issue.body || '(sem descrição)'}`,
303
305
  ].join('\n');
304
306
 
305
- const decomposition = parseJson(await generateDocument(RFC_SYSTEM_PROMPT, userContent));
307
+ const decomposition = parseJson(await generateDocument(RFC_SYSTEM_PROMPT, userContent, { action: 'decompose', usage }));
306
308
  const rfcNodeId = issue.node_id;
307
309
  const tasks = decomposition.tasks || [];
308
310
  const created = [];
@@ -408,7 +410,15 @@ export async function decompose({ issueNumber }) {
408
410
  }
409
411
  }
410
412
 
411
- const ctx = { token, projectToken, owner, repo, issue, issueNumber, project, statusField };
412
- if (type === 'Feature') await decomposeFeature(ctx);
413
- else if (type === 'RFC') await decomposeRFC(ctx);
413
+ // Coletor de uso de IA o finally registra o custo já incorrido mesmo nos
414
+ // fluxos que retornam cedo (ex.: abort da crítica grave) ou que falham.
415
+ const usageEntries = [];
416
+ const ctx = { token, projectToken, owner, repo, issue, issueNumber, project, statusField, usage: usageEntries };
417
+ try {
418
+ if (type === 'Feature') await decomposeFeature(ctx);
419
+ else if (type === 'RFC') await decomposeRFC(ctx);
420
+ } finally {
421
+ // Best-effort: nunca propaga erro (ver recordUsage).
422
+ await recordUsage({ token, owner, repo, issueNumber: parseInt(issueNumber, 10), entries: usageEntries });
423
+ }
414
424
  }
@@ -6,6 +6,7 @@ import { detectIssueType } from '../lib/issue-type.mjs';
6
6
  import { allowsSpecPlan, SPEC_PLAN_EXCLUDED_TYPES, TARGET_LANGUAGE, LABEL_CRITIQUE_FAILED } from '../config.mjs';
7
7
  import { generateDocument } from '../lib/claude.mjs';
8
8
  import { runCritique } from '../lib/critique.mjs';
9
+ import { recordUsage } from '../lib/usage-report.mjs';
9
10
  import { slugify } from '../lib/slugify.mjs';
10
11
  import { buildTechContext } from '../lib/tech-context.mjs';
11
12
 
@@ -91,59 +92,69 @@ export async function generatePlan({ issueNumber }) {
91
92
  };
92
93
  const userContent = `Gere o plan.md a partir deste payload JSON:\n\n${JSON.stringify(payload, null, 2)}`;
93
94
 
94
- console.log(`Gerando plan.md para: ${issue.title}`);
95
- const { content, lintFindings } = await generateDocument(SYSTEM_PROMPT, userContent, {
96
- action: 'plan',
97
- lint: { lang: TARGET_LANGUAGE },
98
- withReport: true,
99
- });
100
-
101
- mkdirSync(featureDir, { recursive: true });
102
- writeFileSync(filePath, content, 'utf-8');
103
-
104
- // Commit and push
105
- const git = (cmd) => execSync(cmd, { stdio: 'inherit' });
106
- git(`git config user.email "spec-wave[bot]@github.com"`);
107
- git(`git config user.name "spec-wave[bot]"`);
108
- git(`git add "${filePath}"`);
109
- git(`git commit -m "docs: generate plan.md for ${slug} [spec-wave]"`);
110
- git('git pull --rebase');
111
- git('git push');
112
-
113
- // Remove trigger label
114
- await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:plan');
115
-
116
- // Comment on issue
117
- await commentOnIssue(
118
- token, owner, repo, parseInt(issueNumber, 10),
119
- `📋 **plan.md gerado automaticamente!**\n\n` +
120
- `📄 Arquivo: [\`${filePath}\`](https://github.com/${owner}/${repo}/blob/main/${filePath})\n\n` +
121
- `Revise o plano e, quando estiver pronto, valide a Feature: mova o card para **✅ Ready** ou use:\n` +
122
- `\`\`\`\ngh issue edit ${issueNumber} --add-label "spec-wave:ready"\n\`\`\`` +
123
- formatLintWarning(lintFindings)
124
- );
125
-
126
- // Crítica adversarial: audita o plan recém-comitado contra spec +
127
- // tech_context. NUNCA desfaz o plan — falha da crítica vira só um aviso.
95
+ // Coletor de uso de IA — o finally registra o custo já incorrido (geração +
96
+ // crítica) mesmo se o fluxo falhar no meio.
97
+ const usageEntries = [];
128
98
  try {
129
- const critique = await runCritique({
130
- kind: 'plan',
131
- spec: specContent,
132
- plan: content,
133
- techContextYaml: tech.yaml,
99
+ console.log(`Gerando plan.md para: ${issue.title}`);
100
+ const { content, lintFindings } = await generateDocument(SYSTEM_PROMPT, userContent, {
101
+ action: 'plan',
102
+ lint: { lang: TARGET_LANGUAGE },
103
+ withReport: true,
104
+ usage: usageEntries,
134
105
  });
135
- await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10), critique.markdown);
136
- if (critique.grave) {
137
- await addLabel(token, owner, repo, parseInt(issueNumber, 10), LABEL_CRITIQUE_FAILED);
138
- console.log(`Crítica adversarial apontou findings GRAVES — label ${LABEL_CRITIQUE_FAILED} aplicada.`);
139
- }
140
- } catch (err) {
141
- console.warn(`Crítica adversarial indisponível: ${err.message}`);
106
+
107
+ mkdirSync(featureDir, { recursive: true });
108
+ writeFileSync(filePath, content, 'utf-8');
109
+
110
+ // Commit and push
111
+ const git = (cmd) => execSync(cmd, { stdio: 'inherit' });
112
+ git(`git config user.email "spec-wave[bot]@github.com"`);
113
+ git(`git config user.name "spec-wave[bot]"`);
114
+ git(`git add "${filePath}"`);
115
+ git(`git commit -m "docs: generate plan.md for ${slug} [spec-wave]"`);
116
+ git('git pull --rebase');
117
+ git('git push');
118
+
119
+ // Remove trigger label
120
+ await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:plan');
121
+
122
+ // Comment on issue
142
123
  await commentOnIssue(
143
124
  token, owner, repo, parseInt(issueNumber, 10),
144
- `⚠️ crítica adversarial indisponível (erro: ${err.message})`
145
- ).catch(() => {});
146
- }
125
+ `📋 **plan.md gerado automaticamente!**\n\n` +
126
+ `📄 Arquivo: [\`${filePath}\`](https://github.com/${owner}/${repo}/blob/main/${filePath})\n\n` +
127
+ `Revise o plano e, quando estiver pronto, valide a Feature: mova o card para **✅ Ready** ou use:\n` +
128
+ `\`\`\`\ngh issue edit ${issueNumber} --add-label "spec-wave:ready"\n\`\`\`` +
129
+ formatLintWarning(lintFindings)
130
+ );
147
131
 
148
- console.log(`plan.md criado em: ${filePath}`);
132
+ // Crítica adversarial: audita o plan recém-comitado contra spec +
133
+ // tech_context. NUNCA desfaz o plan — falha da crítica vira só um aviso.
134
+ try {
135
+ const critique = await runCritique({
136
+ kind: 'plan',
137
+ spec: specContent,
138
+ plan: content,
139
+ techContextYaml: tech.yaml,
140
+ usage: usageEntries,
141
+ });
142
+ await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10), critique.markdown);
143
+ if (critique.grave) {
144
+ await addLabel(token, owner, repo, parseInt(issueNumber, 10), LABEL_CRITIQUE_FAILED);
145
+ console.log(`Crítica adversarial apontou findings GRAVES — label ${LABEL_CRITIQUE_FAILED} aplicada.`);
146
+ }
147
+ } catch (err) {
148
+ console.warn(`Crítica adversarial indisponível: ${err.message}`);
149
+ await commentOnIssue(
150
+ token, owner, repo, parseInt(issueNumber, 10),
151
+ `⚠️ crítica adversarial indisponível (erro: ${err.message})`
152
+ ).catch(() => {});
153
+ }
154
+
155
+ console.log(`plan.md criado em: ${filePath}`);
156
+ } finally {
157
+ // Best-effort: nunca propaga erro (ver recordUsage).
158
+ await recordUsage({ token, owner, repo, issueNumber: parseInt(issueNumber, 10), entries: usageEntries });
159
+ }
149
160
  }
@@ -3,6 +3,7 @@ import { mkdirSync, writeFileSync } from 'node:fs';
3
3
  import { resolveToken } from '../api/auth.mjs';
4
4
  import { getIssue, removeLabel, commentOnIssue } from '../api/github-rest.mjs';
5
5
  import { generateDocument } from '../lib/claude.mjs';
6
+ import { recordUsage } from '../lib/usage-report.mjs';
6
7
  import { slugify } from '../lib/slugify.mjs';
7
8
  import { detectIssueType } from '../lib/issue-type.mjs';
8
9
  import { allowsSpecPlan, SPEC_PLAN_EXCLUDED_TYPES, TARGET_LANGUAGE } from '../config.mjs';
@@ -84,37 +85,46 @@ export async function generateSpec({ issueNumber }) {
84
85
  };
85
86
  const userContent = `Gere o spec.md a partir deste payload JSON:\n\n${JSON.stringify(payload, null, 2)}`;
86
87
 
87
- console.log(`Gerando spec.md para: ${issue.title}`);
88
- const { content, lintFindings } = await generateDocument(SYSTEM_PROMPT, userContent, {
89
- action: 'spec',
90
- lint: { lang: TARGET_LANGUAGE },
91
- withReport: true,
92
- });
93
-
94
- mkdirSync(featureDir, { recursive: true });
95
- writeFileSync(filePath, content, 'utf-8');
96
-
97
- // Commit and push
98
- const git = (cmd) => execSync(cmd, { stdio: 'inherit' });
99
- git(`git config user.email "spec-wave[bot]@github.com"`);
100
- git(`git config user.name "spec-wave[bot]"`);
101
- git(`git add "${filePath}"`);
102
- git(`git commit -m "docs: generate spec.md for ${slug} [spec-wave]"`);
103
- git('git pull --rebase');
104
- git('git push');
105
-
106
- // Remove trigger label
107
- await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:spec');
108
-
109
- // Comment on issue
110
- await commentOnIssue(
111
- token, owner, repo, parseInt(issueNumber, 10),
112
- `📋 **spec.md gerado automaticamente!**\n\n` +
113
- `📄 Arquivo: [\`${filePath}\`](https://github.com/${owner}/${repo}/blob/main/${filePath})\n\n` +
114
- `Revise a especificação e, quando estiver pronto, gere o plano técnico: mova o card para **📋 Plan** ou use:\n` +
115
- `\`\`\`\ngh issue edit ${issueNumber} --add-label "spec-wave:plan"\n\`\`\`` +
116
- formatLintWarning(lintFindings)
117
- );
118
-
119
- console.log(`spec.md criado em: ${filePath}`);
88
+ // Coletor de uso de IA — o finally registra o custo já incorrido mesmo se o
89
+ // fluxo falhar depois da geração.
90
+ const usageEntries = [];
91
+ try {
92
+ console.log(`Gerando spec.md para: ${issue.title}`);
93
+ const { content, lintFindings } = await generateDocument(SYSTEM_PROMPT, userContent, {
94
+ action: 'spec',
95
+ lint: { lang: TARGET_LANGUAGE },
96
+ withReport: true,
97
+ usage: usageEntries,
98
+ });
99
+
100
+ mkdirSync(featureDir, { recursive: true });
101
+ writeFileSync(filePath, content, 'utf-8');
102
+
103
+ // Commit and push
104
+ const git = (cmd) => execSync(cmd, { stdio: 'inherit' });
105
+ git(`git config user.email "spec-wave[bot]@github.com"`);
106
+ git(`git config user.name "spec-wave[bot]"`);
107
+ git(`git add "${filePath}"`);
108
+ git(`git commit -m "docs: generate spec.md for ${slug} [spec-wave]"`);
109
+ git('git pull --rebase');
110
+ git('git push');
111
+
112
+ // Remove trigger label
113
+ await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:spec');
114
+
115
+ // Comment on issue
116
+ await commentOnIssue(
117
+ token, owner, repo, parseInt(issueNumber, 10),
118
+ `📋 **spec.md gerado automaticamente!**\n\n` +
119
+ `📄 Arquivo: [\`${filePath}\`](https://github.com/${owner}/${repo}/blob/main/${filePath})\n\n` +
120
+ `Revise a especificação e, quando estiver pronto, gere o plano técnico: mova o card para **📋 Plan** ou use:\n` +
121
+ `\`\`\`\ngh issue edit ${issueNumber} --add-label "spec-wave:plan"\n\`\`\`` +
122
+ formatLintWarning(lintFindings)
123
+ );
124
+
125
+ console.log(`spec.md criado em: ${filePath}`);
126
+ } finally {
127
+ // Best-effort: nunca propaga erro (ver recordUsage).
128
+ await recordUsage({ token, owner, repo, issueNumber: parseInt(issueNumber, 10), entries: usageEntries });
129
+ }
120
130
  }
@@ -3,6 +3,7 @@ import { readFileSync, existsSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { CONFIG_FILE, getProvider, DEFAULT_PROVIDER } from '../config.mjs';
5
5
  import { lintLanguage } from './output-lint.mjs';
6
+ import { computeCost } from './usage-report.mjs';
6
7
 
7
8
  /**
8
9
  * Resolve provider/modelo de IA (função PURA — testável sem process.env nem fs).
@@ -16,7 +17,7 @@ import { lintLanguage } from './output-lint.mjs';
16
17
  * @param {object} [params.env] objeto tipo process.env
17
18
  * @param {object} [params.fileAi] bloco `ai` do .spec-wave.json
18
19
  * @param {string} [params.action] ação de IA (ver AI_ACTIONS em config.mjs)
19
- * @returns {{ provider: string, model: string, secret: string }}
20
+ * @returns {{ provider: string, model: string, secret: string, pricing: object|null }}
20
21
  */
21
22
  export function resolveAiConfig({ env = {}, fileAi = {}, action } = {}) {
22
23
  const provider = (env.SPEC_WAVE_PROVIDER || fileAi.provider || DEFAULT_PROVIDER).toLowerCase();
@@ -25,7 +26,10 @@ export function resolveAiConfig({ env = {}, fileAi = {}, action } = {}) {
25
26
  || (action && fileAi.models?.[action])
26
27
  || fileAi.model
27
28
  || meta.defaultModel;
28
- return { provider: meta.value, model, secret: meta.secret };
29
+ // pricing: tabela `ai.pricing` do .spec-wave.json ({ [model]: { input,
30
+ // output } } em USD/1M tokens) — usada para estimar custo quando o provider
31
+ // não devolve o valor (Anthropic).
32
+ return { provider: meta.value, model, secret: meta.secret, pricing: fileAi.pricing || null };
29
33
  }
30
34
 
31
35
  // Resolve o provider/modelo de IA a partir do .spec-wave.json (gravado pelo init
@@ -55,7 +59,10 @@ function resolveAi(action) {
55
59
  // RE-GERA uma única vez com instrução de idioma reforçada; se reprovar de
56
60
  // novo, segue com o conteúdo e reporta os findings;
57
61
  // • withReport: true → retorna { content, lintFindings, retried } em vez da
58
- // string (o retorno string é mantido para os chamadores existentes).
62
+ // string (o retorno string é mantido para os chamadores existentes);
63
+ // • usage: array coletor — recebe push de UMA entrada { at, action, provider,
64
+ // model, inputTokens, outputTokens, cost } por invocação (o retry de lint
65
+ // soma tokens/custo na MESMA entrada).
59
66
  export async function generateDocument(systemPrompt, userContent, opts = {}) {
60
67
  const ai = resolveAi(opts.action);
61
68
  const temperature = opts.temperature ?? 0.2;
@@ -64,11 +71,19 @@ export async function generateDocument(systemPrompt, userContent, opts = {}) {
64
71
  const maxTokens = opts.maxTokens ?? 8192;
65
72
  console.log(`Provider de IA: ${ai.provider} · modelo: ${ai.model} · temperature: ${temperature} · max_tokens: ${maxTokens}`);
66
73
 
74
+ // Acumuladores de uso desta invocação (1 ou 2 chamadas, com o retry de lint).
75
+ let inputTokens = 0;
76
+ let outputTokens = 0;
77
+ let cost = null; // soma dos custos conhecidos; se todos null → null
78
+
67
79
  const generate = async (system) => {
68
- const raw = ai.provider === 'openrouter'
80
+ const { text, usage } = ai.provider === 'openrouter'
69
81
  ? await generateWithOpenRouter(system, userContent, ai, temperature, maxTokens)
70
82
  : await generateWithAnthropic(system, userContent, ai, temperature, maxTokens);
71
- return stripOuterFence(raw);
83
+ inputTokens += usage.inputTokens;
84
+ outputTokens += usage.outputTokens;
85
+ if (typeof usage.cost === 'number') cost = (cost ?? 0) + usage.cost;
86
+ return stripOuterFence(text);
72
87
  };
73
88
 
74
89
  let content = await generate(systemPrompt);
@@ -97,6 +112,23 @@ export async function generateDocument(systemPrompt, userContent, opts = {}) {
97
112
  }
98
113
  }
99
114
 
115
+ // Telemetria: uma entrada por invocação no coletor passado pelo chamador.
116
+ // A Anthropic não devolve custo — estima via tabela ai.pricing, se houver.
117
+ if (Array.isArray(opts.usage)) {
118
+ if (cost === null && ai.provider === 'anthropic') {
119
+ cost = computeCost({ model: ai.model, inputTokens, outputTokens, pricing: ai.pricing });
120
+ }
121
+ opts.usage.push({
122
+ at: new Date().toISOString(),
123
+ action: opts.action || 'unknown',
124
+ provider: ai.provider,
125
+ model: ai.model,
126
+ inputTokens,
127
+ outputTokens,
128
+ cost,
129
+ });
130
+ }
131
+
100
132
  return opts.withReport ? { content, lintFindings, retried } : content;
101
133
  }
102
134
 
@@ -116,6 +148,37 @@ function stripOuterFence(text) {
116
148
  return m ? m[1].trim() : t;
117
149
  }
118
150
 
151
+ /**
152
+ * Normaliza o bloco `usage` da API da Anthropic (função PURA — testável).
153
+ * A Anthropic não informa custo em USD → cost sempre null (estimado depois
154
+ * via computeCost, se houver tabela de preços).
155
+ *
156
+ * @param {object} [usage] `message.usage` da resposta ({ input_tokens, output_tokens })
157
+ * @returns {{ inputTokens: number, outputTokens: number, cost: null }}
158
+ */
159
+ export function extractAnthropicUsage(usage) {
160
+ return {
161
+ inputTokens: usage?.input_tokens ?? 0,
162
+ outputTokens: usage?.output_tokens ?? 0,
163
+ cost: null,
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Normaliza o bloco `usage` da API do OpenRouter (função PURA — testável).
169
+ * Com `usage: { include: true }` no request, o OpenRouter devolve `cost` em USD.
170
+ *
171
+ * @param {object} [usage] `data.usage` da resposta ({ prompt_tokens, completion_tokens, cost })
172
+ * @returns {{ inputTokens: number, outputTokens: number, cost: number|null }}
173
+ */
174
+ export function extractOpenRouterUsage(usage) {
175
+ return {
176
+ inputTokens: usage?.prompt_tokens ?? 0,
177
+ outputTokens: usage?.completion_tokens ?? 0,
178
+ cost: typeof usage?.cost === 'number' ? usage.cost : null,
179
+ };
180
+ }
181
+
119
182
  async function generateWithAnthropic(systemPrompt, userContent, ai, temperature, maxTokens) {
120
183
  const apiKey = process.env.ANTHROPIC_API_KEY;
121
184
  if (!apiKey) {
@@ -134,7 +197,7 @@ async function generateWithAnthropic(systemPrompt, userContent, ai, temperature,
134
197
  system: systemPrompt,
135
198
  });
136
199
 
137
- return message.content[0].text;
200
+ return { text: message.content[0].text, usage: extractAnthropicUsage(message.usage) };
138
201
  }
139
202
 
140
203
  async function generateWithOpenRouter(systemPrompt, userContent, ai, temperature, maxTokens) {
@@ -162,6 +225,8 @@ async function generateWithOpenRouter(systemPrompt, userContent, ai, temperature
162
225
  { role: 'system', content: systemPrompt },
163
226
  { role: 'user', content: userContent },
164
227
  ],
228
+ // Pede o bloco `usage` completo na resposta (inclui `cost` em USD).
229
+ usage: { include: true },
165
230
  }),
166
231
  });
167
232
 
@@ -175,5 +240,5 @@ async function generateWithOpenRouter(systemPrompt, userContent, ai, temperature
175
240
  if (!content) {
176
241
  throw new Error(`OpenRouter retornou resposta vazia: ${JSON.stringify(data)}`);
177
242
  }
178
- return content;
243
+ return { text: content, usage: extractOpenRouterUsage(data.usage) };
179
244
  }
@@ -137,9 +137,10 @@ function renderMarkdown(findings) {
137
137
  * @param {string} [params.plan] conteúdo do plan.md
138
138
  * @param {string} [params.techContextYaml] tech_context serializado em YAML
139
139
  * @param {object[]} [params.stories] stories propostas (antes da criação)
140
+ * @param {object[]} [params.usage] coletor de uso de IA (repassado ao generateDocument)
140
141
  * @returns {Promise<{ grave: boolean, findings: Array<{ severity: string, text: string }>, markdown: string }>}
141
142
  */
142
- export async function runCritique({ kind, spec, plan, techContextYaml, stories } = {}) {
143
+ export async function runCritique({ kind, spec, plan, techContextYaml, stories, usage } = {}) {
143
144
  const sections = [];
144
145
  if (spec) sections.push(`## spec.md\n\n${spec}`);
145
146
  if (plan) sections.push(`## plan.md\n\n${plan}`);
@@ -151,6 +152,7 @@ export async function runCritique({ kind, spec, plan, techContextYaml, stories }
151
152
  action: 'critique',
152
153
  temperature: 0,
153
154
  maxTokens: 4096,
155
+ usage,
154
156
  });
155
157
 
156
158
  const { grave, findings } = parseCritiqueResponse(raw);
@@ -0,0 +1,167 @@
1
+ // Telemetria de uso de IA (tokens/custo por chamada) → comentário único
2
+ // acumulado na issue da Feature.
3
+ //
4
+ // Cada invocação de generateDocument gera UMA entrada { at, action, provider,
5
+ // model, inputTokens, outputTokens, cost }. O recordUsage acumula as entradas
6
+ // num comentário markdown identificado pelo USAGE_MARKER: parse do comentário
7
+ // existente → concat com as novas → re-render → update (ou create).
8
+ //
9
+ // Contrato: registrar uso NUNCA derruba o fluxo principal — recordUsage é
10
+ // best-effort e engole qualquer erro com um console.warn.
11
+
12
+ import { listIssueComments, updateComment, commentOnIssue } from '../api/github-rest.mjs';
13
+
14
+ // Marcador HTML invisível que identifica o comentário de uso na issue.
15
+ export const USAGE_MARKER = '<!-- spec-wave:usage -->';
16
+
17
+ // Teto de linhas de dados na tabela — ao exceder, descarta as mais antigas
18
+ // (o comentário não pode crescer sem limite em Features longevas).
19
+ const MAX_ROWS = 100;
20
+
21
+ /**
22
+ * Calcula o custo em USD a partir da tabela de preços (função PURA — testável).
23
+ *
24
+ * `pricing` vem do bloco `ai.pricing` do .spec-wave.json, no formato
25
+ * `{ [model]: { input, output } }` em USD por 1M de tokens. Sem pricing ou sem
26
+ * o modelo na tabela → null (custo desconhecido, nunca chuta).
27
+ *
28
+ * @param {object} params
29
+ * @param {string} params.model modelo usado na chamada
30
+ * @param {number} params.inputTokens tokens de entrada
31
+ * @param {number} params.outputTokens tokens de saída
32
+ * @param {object|null} [params.pricing] tabela de preços por modelo
33
+ * @returns {number|null} custo em USD, ou null se desconhecido
34
+ */
35
+ export function computeCost({ model, inputTokens, outputTokens, pricing } = {}) {
36
+ const price = pricing?.[model];
37
+ if (!price || typeof price.input !== 'number' || typeof price.output !== 'number') {
38
+ return null;
39
+ }
40
+ return ((inputTokens || 0) * price.input + (outputTokens || 0) * price.output) / 1_000_000;
41
+ }
42
+
43
+ // ISO timestamp → "YYYY-MM-DD HH:mm" em UTC (formato da coluna Data).
44
+ function formatAt(at) {
45
+ const date = new Date(at);
46
+ if (Number.isNaN(date.getTime())) return String(at || '');
47
+ return date.toISOString().slice(0, 16).replace('T', ' ');
48
+ }
49
+
50
+ // Custo → "$X.XXXX"; null (desconhecido) → travessão.
51
+ function formatCost(cost) {
52
+ return typeof cost === 'number' ? `$${cost.toFixed(4)}` : '—';
53
+ }
54
+
55
+ /**
56
+ * Interpreta o comentário de uso de volta para entries (função PURA — round-trip
57
+ * do renderUsageComment). Linhas de header, separador e Total são ignoradas.
58
+ * `at` é reconstruído como `YYYY-MM-DDTHH:mm:00.000Z` (segundos zerados).
59
+ *
60
+ * @param {string} body corpo do comentário
61
+ * @returns {Array<object>|null} entries, ou null se sem marcador ou tabela irreconhecível
62
+ */
63
+ export function parseUsageComment(body) {
64
+ const text = body || '';
65
+ if (!text.includes(USAGE_MARKER)) return null;
66
+
67
+ const entries = [];
68
+ let sawHeader = false;
69
+ for (const line of text.split('\n')) {
70
+ const trimmed = line.trim();
71
+ if (!trimmed.startsWith('|')) continue;
72
+ const cells = trimmed.split('|').slice(1, -1).map(c => c.trim());
73
+ if (cells.length !== 6) continue;
74
+ if (cells[0] === 'Data (UTC)') { sawHeader = true; continue; } // header
75
+ if (/^:?-{3,}:?$/.test(cells[0])) continue; // separador
76
+ if (cells[0].includes('Total')) continue; // linha Total
77
+
78
+ const dateMatch = cells[0].match(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2})$/);
79
+ const inputTokens = Number.parseInt(cells[3], 10);
80
+ const outputTokens = Number.parseInt(cells[4], 10);
81
+ if (!dateMatch || Number.isNaN(inputTokens) || Number.isNaN(outputTokens)) continue;
82
+
83
+ const costMatch = cells[5].match(/^\$(\d+(?:\.\d+)?)$/);
84
+ entries.push({
85
+ at: `${dateMatch[1]}T${dateMatch[2]}:00.000Z`,
86
+ action: cells[1],
87
+ model: cells[2],
88
+ inputTokens,
89
+ outputTokens,
90
+ cost: costMatch ? Number.parseFloat(costMatch[1]) : null,
91
+ });
92
+ }
93
+
94
+ // Marcador presente mas tabela irreconhecível (sem header ou sem nenhuma
95
+ // linha de dados válida) → null: o chamador recomeça só com as novas entradas.
96
+ if (!sawHeader || entries.length === 0) return null;
97
+ return entries;
98
+ }
99
+
100
+ /**
101
+ * Renderiza as entries como comentário markdown (função PURA — testável).
102
+ *
103
+ * Cap de MAX_ROWS linhas de dados (descarta as mais antigas). A linha Total
104
+ * soma os tokens de todas as linhas exibidas e só os custos conhecidos —
105
+ * custos indisponíveis (—) ficam fora do total.
106
+ *
107
+ * @param {Array<object>} entries entradas de uso
108
+ * @returns {string} corpo markdown do comentário
109
+ */
110
+ export function renderUsageComment(entries) {
111
+ const rows = (entries || []).slice(-MAX_ROWS);
112
+
113
+ const totalIn = rows.reduce((sum, e) => sum + (e.inputTokens || 0), 0);
114
+ const totalOut = rows.reduce((sum, e) => sum + (e.outputTokens || 0), 0);
115
+ const known = rows.filter(e => typeof e.cost === 'number');
116
+ const totalCost = known.length > 0
117
+ ? formatCost(known.reduce((sum, e) => sum + e.cost, 0))
118
+ : '—';
119
+
120
+ return [
121
+ USAGE_MARKER,
122
+ '📊 **Uso de IA (spec-wave)**',
123
+ '',
124
+ '| Data (UTC) | Ação | Modelo | Tokens in | Tokens out | Custo (USD) |',
125
+ '| --- | --- | --- | ---: | ---: | ---: |',
126
+ ...rows.map(e =>
127
+ `| ${formatAt(e.at)} | ${e.action} | ${e.model} | ${e.inputTokens} | ${e.outputTokens} | ${formatCost(e.cost)} |`
128
+ ),
129
+ `| **Total** | | | **${totalIn}** | **${totalOut}** | **${totalCost}** |`,
130
+ '',
131
+ '_Custos indisponíveis (—) não entram no total. Atualizado automaticamente pelo spec-wave._',
132
+ ].join('\n');
133
+ }
134
+
135
+ /**
136
+ * Registra as entries no comentário de uso da issue (impura, best-effort).
137
+ *
138
+ * Acha o comentário com USAGE_MARKER, faz parse das entradas antigas, concatena
139
+ * as novas e atualiza (ou cria, se não existir). Qualquer erro vira só um
140
+ * console.warn — telemetria nunca derruba o fluxo principal.
141
+ *
142
+ * @param {object} params
143
+ * @param {string} params.token token do GitHub
144
+ * @param {string} params.owner dono do repo
145
+ * @param {string} params.repo nome do repo
146
+ * @param {number} params.issueNumber número da issue
147
+ * @param {Array<object>} params.entries novas entradas de uso
148
+ */
149
+ export async function recordUsage({ token, owner, repo, issueNumber, entries } = {}) {
150
+ if (!Array.isArray(entries) || entries.length === 0) return;
151
+ try {
152
+ const comments = await listIssueComments(token, owner, repo, issueNumber);
153
+ const existing = comments.find(c => (c.body || '').includes(USAGE_MARKER));
154
+
155
+ // Comentário antigo irreconhecível → recomeça só com as novas entradas.
156
+ const previous = existing ? parseUsageComment(existing.body) : null;
157
+ const body = renderUsageComment([...(previous || []), ...entries]);
158
+
159
+ if (existing?.id) {
160
+ await updateComment(token, owner, repo, existing.id, body);
161
+ } else {
162
+ await commentOnIssue(token, owner, repo, issueNumber, body);
163
+ }
164
+ } catch (err) {
165
+ console.warn('Não foi possível registrar uso de IA:', err.message);
166
+ }
167
+ }