@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 +1 -1
- package/src/api/github-rest.mjs +17 -2
- package/src/commands/decompose.mjs +17 -7
- package/src/commands/generate-plan.mjs +61 -50
- package/src/commands/generate-spec.mjs +43 -33
- package/src/lib/claude.mjs +72 -7
- package/src/lib/critique.mjs +3 -1
- package/src/lib/usage-report.mjs +167 -0
package/package.json
CHANGED
package/src/api/github-rest.mjs
CHANGED
|
@@ -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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
}
|
package/src/lib/claude.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/lib/critique.mjs
CHANGED
|
@@ -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
|
+
}
|