@spec-wave/cli 0.7.0 → 0.8.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.
- package/package.json +1 -1
- package/src/api/github-rest.mjs +17 -2
- package/src/commands/decompose.mjs +44 -48
- package/src/commands/doctor.mjs +32 -0
- package/src/commands/generate-plan.mjs +62 -50
- package/src/commands/generate-spec.mjs +45 -33
- package/src/commands/info.mjs +42 -4
- package/src/commands/install-skill.mjs +40 -0
- package/src/commands/update.mjs +4 -13
- package/src/config.mjs +1 -0
- package/src/lib/claude.mjs +72 -7
- package/src/lib/critique.mjs +3 -1
- package/src/lib/usage-report.mjs +167 -0
- package/src/templates/skill/SKILL.md +6 -3
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.
|
|
@@ -1,42 +1,22 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
2
|
import { resolveToken } from '../api/auth.mjs';
|
|
4
3
|
import { getIssue, createIssue, removeLabel, addLabel, commentOnIssue, addBlockedBy } from '../api/github-rest.mjs';
|
|
5
|
-
import { addSubIssue,
|
|
4
|
+
import { addSubIssue, listSubIssues } from '../api/github-graphql.mjs';
|
|
5
|
+
import { loadProjectConfig, resolveField, advanceToStage } from '../lib/board.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';
|
|
11
12
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
12
|
-
import {
|
|
13
|
+
import { DECOMPOSE_TARGETS, LABEL_DECOMPOSED, LABEL_CRITIQUE_FAILED, TARGET_LANGUAGE, STAGE_READY, PROGRESS_TODO } from '../config.mjs';
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (!existsSync(configPath)) return null;
|
|
20
|
-
try {
|
|
21
|
-
return JSON.parse(readFileSync(configPath, 'utf-8')).project || null;
|
|
22
|
-
} catch {
|
|
23
|
-
return null;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// Resolve o campo Status do Project: usa .spec-wave.json ou consulta API.
|
|
28
|
-
async function resolveStatusField(token, project) {
|
|
29
|
-
if (project.fields?.Status) return project.fields.Status;
|
|
30
|
-
return await getSingleSelectField(token, project.id, 'Status');
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// Adiciona issue ao board e move para a etapa informada. Best-effort.
|
|
34
|
-
async function moveToStage(token, project, statusField, nodeId, stageName) {
|
|
35
|
-
if (!project?.id || !statusField) return;
|
|
36
|
-
const optionId = statusField.options?.[stageName];
|
|
37
|
-
if (!statusField.id || !optionId) return;
|
|
38
|
-
const itemId = await addProjectItem(token, project.id, nodeId);
|
|
39
|
-
await setItemSingleSelect(token, project.id, itemId, statusField.id, optionId);
|
|
15
|
+
// Adiciona a issue ao board na Etapa ✅ Ready / Status Todo. Best-effort; a
|
|
16
|
+
// Etapa nunca retrocede (advanceToStage não toca itens já adiante).
|
|
17
|
+
async function moveToReady(token, project, etapaField, statusField, nodeId) {
|
|
18
|
+
if (!project?.id) return;
|
|
19
|
+
await advanceToStage(token, project, etapaField, statusField, nodeId, STAGE_READY, PROGRESS_TODO);
|
|
40
20
|
}
|
|
41
21
|
|
|
42
22
|
// Extrai JSON da resposta do modelo (tolera texto em volta).
|
|
@@ -148,7 +128,7 @@ Regras:
|
|
|
148
128
|
|
|
149
129
|
// Decompõe uma Feature em Stories (+ Tasks), cada uma vinculada como sub-issue.
|
|
150
130
|
async function decomposeFeature(ctx) {
|
|
151
|
-
const { token, projectToken, owner, repo, issue, issueNumber, project, statusField } = ctx;
|
|
131
|
+
const { token, projectToken, owner, repo, issue, issueNumber, project, etapaField, statusField, usage } = ctx;
|
|
152
132
|
const slug = slugify(issue.title);
|
|
153
133
|
const featureDir = `docs/features/${slug}`;
|
|
154
134
|
|
|
@@ -167,7 +147,7 @@ async function decomposeFeature(ctx) {
|
|
|
167
147
|
`\n## plan.md\n${planContent}`,
|
|
168
148
|
].join('\n');
|
|
169
149
|
|
|
170
|
-
const decomposition = parseJson(await generateDocument(FEATURE_SYSTEM_PROMPT, userContent));
|
|
150
|
+
const decomposition = parseJson(await generateDocument(FEATURE_SYSTEM_PROMPT, userContent, { action: 'decompose', usage }));
|
|
171
151
|
|
|
172
152
|
// Crítica adversarial ANTES de criar qualquer issue: stories que contradizem
|
|
173
153
|
// a spec/plan não devem virar trabalho. Crítica indisponível → só avisa.
|
|
@@ -178,6 +158,7 @@ async function decomposeFeature(ctx) {
|
|
|
178
158
|
spec: existsSync(`${featureDir}/spec.md`) ? specContent : null,
|
|
179
159
|
plan: existsSync(`${featureDir}/plan.md`) ? planContent : null,
|
|
180
160
|
stories: decomposition.stories,
|
|
161
|
+
usage,
|
|
181
162
|
});
|
|
182
163
|
} catch (err) {
|
|
183
164
|
console.warn(`Crítica adversarial indisponível: ${err.message}`);
|
|
@@ -241,9 +222,9 @@ async function decomposeFeature(ctx) {
|
|
|
241
222
|
console.warn(` Story #${createdStory.number} criada, mas falhou ao vincular à Feature: ${err.message}`);
|
|
242
223
|
}
|
|
243
224
|
try {
|
|
244
|
-
await
|
|
225
|
+
await moveToReady(projectToken, project, etapaField, statusField, createdStory.nodeId);
|
|
245
226
|
} catch (err) {
|
|
246
|
-
console.warn(` Falha ao mover story #${createdStory.number} para "${
|
|
227
|
+
console.warn(` Falha ao mover story #${createdStory.number} para "${STAGE_READY}": ${err.message}`);
|
|
247
228
|
}
|
|
248
229
|
|
|
249
230
|
for (const task of story.tasks || []) {
|
|
@@ -258,18 +239,18 @@ async function decomposeFeature(ctx) {
|
|
|
258
239
|
console.warn(` Task #${createdTask.number} criada, mas falhou ao vincular à Story: ${err.message}`);
|
|
259
240
|
}
|
|
260
241
|
try {
|
|
261
|
-
await
|
|
242
|
+
await moveToReady(projectToken, project, etapaField, statusField, createdTask.nodeId);
|
|
262
243
|
} catch (err) {
|
|
263
|
-
console.warn(` Falha ao mover task #${createdTask.number} para "${
|
|
244
|
+
console.warn(` Falha ao mover task #${createdTask.number} para "${STAGE_READY}": ${err.message}`);
|
|
264
245
|
}
|
|
265
246
|
}
|
|
266
247
|
}
|
|
267
248
|
|
|
268
249
|
try {
|
|
269
|
-
await
|
|
270
|
-
if (project?.id &&
|
|
250
|
+
await moveToReady(projectToken, project, etapaField, statusField, featureNodeId);
|
|
251
|
+
if (project?.id && etapaField) console.log(`Feature movida para "${STAGE_READY}" no board.`);
|
|
271
252
|
} catch (err) {
|
|
272
|
-
console.warn(`Falha ao mover Feature para "${
|
|
253
|
+
console.warn(`Falha ao mover Feature para "${STAGE_READY}": ${err.message}`);
|
|
273
254
|
}
|
|
274
255
|
|
|
275
256
|
// Marca a Feature como decomposta (guard de idempotência em runs futuros).
|
|
@@ -293,7 +274,7 @@ async function decomposeFeature(ctx) {
|
|
|
293
274
|
// Decompõe um RFC diretamente em Tasks (sem Stories), cada uma vinculada como
|
|
294
275
|
// sub-issue do RFC.
|
|
295
276
|
async function decomposeRFC(ctx) {
|
|
296
|
-
const { token, projectToken, owner, repo, issue, issueNumber, project, statusField } = ctx;
|
|
277
|
+
const { token, projectToken, owner, repo, issue, issueNumber, project, etapaField, statusField, usage } = ctx;
|
|
297
278
|
console.log(`Decompondo RFC: ${issue.title}`);
|
|
298
279
|
|
|
299
280
|
const userContent = [
|
|
@@ -302,7 +283,7 @@ async function decomposeRFC(ctx) {
|
|
|
302
283
|
`\n## Descrição\n${issue.body || '(sem descrição)'}`,
|
|
303
284
|
].join('\n');
|
|
304
285
|
|
|
305
|
-
const decomposition = parseJson(await generateDocument(RFC_SYSTEM_PROMPT, userContent));
|
|
286
|
+
const decomposition = parseJson(await generateDocument(RFC_SYSTEM_PROMPT, userContent, { action: 'decompose', usage }));
|
|
306
287
|
const rfcNodeId = issue.node_id;
|
|
307
288
|
const tasks = decomposition.tasks || [];
|
|
308
289
|
const created = [];
|
|
@@ -322,9 +303,9 @@ async function decomposeRFC(ctx) {
|
|
|
322
303
|
console.warn(` Task #${createdTask.number} criada, mas falhou ao vincular ao RFC: ${err.message}`);
|
|
323
304
|
}
|
|
324
305
|
try {
|
|
325
|
-
await
|
|
306
|
+
await moveToReady(projectToken, project, etapaField, statusField, createdTask.nodeId);
|
|
326
307
|
} catch (err) {
|
|
327
|
-
console.warn(` Falha ao mover task #${createdTask.number} para "${
|
|
308
|
+
console.warn(` Falha ao mover task #${createdTask.number} para "${STAGE_READY}": ${err.message}`);
|
|
328
309
|
}
|
|
329
310
|
}
|
|
330
311
|
|
|
@@ -397,18 +378,33 @@ export async function decompose({ issueNumber }) {
|
|
|
397
378
|
return;
|
|
398
379
|
}
|
|
399
380
|
|
|
400
|
-
// Projeto +
|
|
401
|
-
const project =
|
|
381
|
+
// Projeto + campos Etapa/Status do board (reutilizados em todos os itens).
|
|
382
|
+
const { project, error: projectError } = loadProjectConfig();
|
|
383
|
+
if (projectError) console.warn(`${projectError} — itens criados não serão posicionados no board.`);
|
|
384
|
+
let etapaField = null;
|
|
402
385
|
let statusField = null;
|
|
403
386
|
if (project?.id) {
|
|
404
387
|
try {
|
|
405
|
-
|
|
388
|
+
etapaField = await resolveField(projectToken, project, 'Etapa');
|
|
389
|
+
} catch (err) {
|
|
390
|
+
console.warn(`Não foi possível resolver campo Etapa do board: ${err.message}`);
|
|
391
|
+
}
|
|
392
|
+
try {
|
|
393
|
+
statusField = await resolveField(projectToken, project, 'Status');
|
|
406
394
|
} catch (err) {
|
|
407
395
|
console.warn(`Não foi possível resolver campo Status do board: ${err.message}`);
|
|
408
396
|
}
|
|
409
397
|
}
|
|
410
398
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
399
|
+
// Coletor de uso de IA — o finally registra o custo já incorrido mesmo nos
|
|
400
|
+
// fluxos que retornam cedo (ex.: abort da crítica grave) ou que falham.
|
|
401
|
+
const usageEntries = [];
|
|
402
|
+
const ctx = { token, projectToken, owner, repo, issue, issueNumber, project, etapaField, statusField, usage: usageEntries };
|
|
403
|
+
try {
|
|
404
|
+
if (type === 'Feature') await decomposeFeature(ctx);
|
|
405
|
+
else if (type === 'RFC') await decomposeRFC(ctx);
|
|
406
|
+
} finally {
|
|
407
|
+
// Best-effort: nunca propaga erro (ver recordUsage).
|
|
408
|
+
await recordUsage({ token, owner, repo, issueNumber: parseInt(issueNumber, 10), entries: usageEntries });
|
|
409
|
+
}
|
|
414
410
|
}
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -333,6 +333,37 @@ async function checkAi(ctx) {
|
|
|
333
333
|
return { name, status, detail: notes.join('\n') };
|
|
334
334
|
}
|
|
335
335
|
|
|
336
|
+
// Exportado para teste: só lê ctx.cfg e process.env — sem rede/filesystem.
|
|
337
|
+
export function checkSpecKit(ctx) {
|
|
338
|
+
const name = 'Spec-kit (specKit.command para o implement)';
|
|
339
|
+
const fromEnv = process.env.SPEC_WAVE_IMPLEMENT_CMD;
|
|
340
|
+
const fromConfig = ctx.cfg?.specKit?.command;
|
|
341
|
+
if (fromEnv) {
|
|
342
|
+
return {
|
|
343
|
+
name,
|
|
344
|
+
status: 'ok',
|
|
345
|
+
detail: `Definido via env SPEC_WAVE_IMPLEMENT_CMD${fromConfig ? ' (sobrepõe o specKit.command do config)' : ''}: ${fromEnv}`,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
if (fromConfig) {
|
|
349
|
+
return { name, status: 'ok', detail: `Definido no ${CONFIG_FILE}: ${fromConfig}` };
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
name,
|
|
353
|
+
status: 'warn',
|
|
354
|
+
detail:
|
|
355
|
+
'Nenhum comando configurado — `implement` só monta o contexto, sem acionar um agente.\n' +
|
|
356
|
+
`Defina "specKit": { "command": "..." } no ${CONFIG_FILE} (ou a env SPEC_WAVE_IMPLEMENT_CMD).\n` +
|
|
357
|
+
'Placeholders: {tasksFile} {specFile} {planFile} {issue} {type} {title}. Exemplos por agente:\n' +
|
|
358
|
+
' Claude Code: claude -p "Implemente as tasks descritas em {tasksFile}"\n' +
|
|
359
|
+
' opencode: opencode run "Implemente as tasks descritas em {tasksFile}"\n' +
|
|
360
|
+
' Codex: codex exec "Implemente as tasks descritas em {tasksFile}"\n' +
|
|
361
|
+
' Copilot CLI: copilot -p "Implemente as tasks descritas em {tasksFile}" --allow-all-tools\n' +
|
|
362
|
+
' Kiro CLI: kiro-cli chat --no-interactive --trust-all-tools "Implemente as tasks descritas em {tasksFile}"\n' +
|
|
363
|
+
' Qwen Code: qwen -p "Implemente as tasks descritas em {tasksFile}"',
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
336
367
|
async function checkWorkflows(ctx) {
|
|
337
368
|
const name = 'Workflows do Actions';
|
|
338
369
|
const dir = path.join(ctx.cwd, '.github', 'workflows');
|
|
@@ -380,6 +411,7 @@ export async function doctor() {
|
|
|
380
411
|
checkConfig,
|
|
381
412
|
checkRepoAccess,
|
|
382
413
|
checkAi,
|
|
414
|
+
checkSpecKit,
|
|
383
415
|
checkWorkflows,
|
|
384
416
|
];
|
|
385
417
|
const results = [];
|
|
@@ -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
|
|
|
@@ -26,6 +27,7 @@ O plano deve conter EXATAMENTE estas seções em português, nesta ordem:
|
|
|
26
27
|
# Estratégia Técnica
|
|
27
28
|
- Abordagem Arquitetural, Decisões-Chave e uma Matriz de Rastreabilidade (tabela) ligando cada Critério de Aceite do spec a um componente técnico.
|
|
28
29
|
# Detalhamento da Implementação
|
|
30
|
+
- Abra a seção com um diagrama de sequência Mermaid (bloco \`\`\`mermaid iniciado com sequenceDiagram) do fluxo principal ponta a ponta, com os componentes técnicos reais como participants (frontend, endpoints/controllers, services, banco de dados, filas). Use APENAS componentes do tech_context ou definidos neste plano; rotule as mensagens com os caminhos de endpoint e nomes de método reais, em português.
|
|
29
31
|
- Subseções: ## Backend, ## Banco de Dados, ## Frontend, ## Infraestrutura.
|
|
30
32
|
# Segurança e Conformidade
|
|
31
33
|
# Estratégia de Testes
|
|
@@ -91,59 +93,69 @@ export async function generatePlan({ issueNumber }) {
|
|
|
91
93
|
};
|
|
92
94
|
const userContent = `Gere o plan.md a partir deste payload JSON:\n\n${JSON.stringify(payload, null, 2)}`;
|
|
93
95
|
|
|
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.
|
|
96
|
+
// Coletor de uso de IA — o finally registra o custo já incorrido (geração +
|
|
97
|
+
// crítica) mesmo se o fluxo falhar no meio.
|
|
98
|
+
const usageEntries = [];
|
|
128
99
|
try {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
100
|
+
console.log(`Gerando plan.md para: ${issue.title}`);
|
|
101
|
+
const { content, lintFindings } = await generateDocument(SYSTEM_PROMPT, userContent, {
|
|
102
|
+
action: 'plan',
|
|
103
|
+
lint: { lang: TARGET_LANGUAGE },
|
|
104
|
+
withReport: true,
|
|
105
|
+
usage: usageEntries,
|
|
134
106
|
});
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
107
|
+
|
|
108
|
+
mkdirSync(featureDir, { recursive: true });
|
|
109
|
+
writeFileSync(filePath, content, 'utf-8');
|
|
110
|
+
|
|
111
|
+
// Commit and push
|
|
112
|
+
const git = (cmd) => execSync(cmd, { stdio: 'inherit' });
|
|
113
|
+
git(`git config user.email "spec-wave[bot]@github.com"`);
|
|
114
|
+
git(`git config user.name "spec-wave[bot]"`);
|
|
115
|
+
git(`git add "${filePath}"`);
|
|
116
|
+
git(`git commit -m "docs: generate plan.md for ${slug} [spec-wave]"`);
|
|
117
|
+
git('git pull --rebase');
|
|
118
|
+
git('git push');
|
|
119
|
+
|
|
120
|
+
// Remove trigger label
|
|
121
|
+
await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:plan');
|
|
122
|
+
|
|
123
|
+
// Comment on issue
|
|
142
124
|
await commentOnIssue(
|
|
143
125
|
token, owner, repo, parseInt(issueNumber, 10),
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
126
|
+
`📋 **plan.md gerado automaticamente!**\n\n` +
|
|
127
|
+
`📄 Arquivo: [\`${filePath}\`](https://github.com/${owner}/${repo}/blob/main/${filePath})\n\n` +
|
|
128
|
+
`Revise o plano e, quando estiver pronto, valide a Feature: mova o card para **✅ Ready** ou use:\n` +
|
|
129
|
+
`\`\`\`\ngh issue edit ${issueNumber} --add-label "spec-wave:ready"\n\`\`\`` +
|
|
130
|
+
formatLintWarning(lintFindings)
|
|
131
|
+
);
|
|
147
132
|
|
|
148
|
-
|
|
133
|
+
// Crítica adversarial: audita o plan recém-comitado contra spec +
|
|
134
|
+
// tech_context. NUNCA desfaz o plan — falha da crítica vira só um aviso.
|
|
135
|
+
try {
|
|
136
|
+
const critique = await runCritique({
|
|
137
|
+
kind: 'plan',
|
|
138
|
+
spec: specContent,
|
|
139
|
+
plan: content,
|
|
140
|
+
techContextYaml: tech.yaml,
|
|
141
|
+
usage: usageEntries,
|
|
142
|
+
});
|
|
143
|
+
await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10), critique.markdown);
|
|
144
|
+
if (critique.grave) {
|
|
145
|
+
await addLabel(token, owner, repo, parseInt(issueNumber, 10), LABEL_CRITIQUE_FAILED);
|
|
146
|
+
console.log(`Crítica adversarial apontou findings GRAVES — label ${LABEL_CRITIQUE_FAILED} aplicada.`);
|
|
147
|
+
}
|
|
148
|
+
} catch (err) {
|
|
149
|
+
console.warn(`Crítica adversarial indisponível: ${err.message}`);
|
|
150
|
+
await commentOnIssue(
|
|
151
|
+
token, owner, repo, parseInt(issueNumber, 10),
|
|
152
|
+
`⚠️ crítica adversarial indisponível (erro: ${err.message})`
|
|
153
|
+
).catch(() => {});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
console.log(`plan.md criado em: ${filePath}`);
|
|
157
|
+
} finally {
|
|
158
|
+
// Best-effort: nunca propaga erro (ver recordUsage).
|
|
159
|
+
await recordUsage({ token, owner, repo, issueNumber: parseInt(issueNumber, 10), entries: usageEntries });
|
|
160
|
+
}
|
|
149
161
|
}
|
|
@@ -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';
|
|
@@ -26,6 +27,8 @@ O spec deve conter EXATAMENTE estas seções em português, nesta ordem:
|
|
|
26
27
|
# Regras de Negócio
|
|
27
28
|
# Fluxos
|
|
28
29
|
- Subseções: ## Fluxo Principal (Happy Path), ## Fluxos Alternativos, ## Cenários de Erro.
|
|
30
|
+
- O Fluxo Principal DEVE conter, além da descrição passo a passo, um diagrama de sequência Mermaid (bloco \`\`\`mermaid iniciado com sequenceDiagram) mostrando a interação entre as personas (actor) e o sistema (participant). Rotule mensagens e notas em português.
|
|
31
|
+
- Cubra os Fluxos Alternativos e Cenários de Erro relevantes no mesmo diagrama usando blocos alt/opt/break — ou, se ficarem complexos, em um segundo diagrama na subseção correspondente.
|
|
29
32
|
# Critérios de Aceite
|
|
30
33
|
- OBRIGATORIAMENTE no formato Gherkin, dentro de um bloco \`\`\`gherkin com Given/When/Then. Um cenário por critério.
|
|
31
34
|
# Dependências
|
|
@@ -84,37 +87,46 @@ export async function generateSpec({ issueNumber }) {
|
|
|
84
87
|
};
|
|
85
88
|
const userContent = `Gere o spec.md a partir deste payload JSON:\n\n${JSON.stringify(payload, null, 2)}`;
|
|
86
89
|
|
|
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
|
-
|
|
90
|
+
// Coletor de uso de IA — o finally registra o custo já incorrido mesmo se o
|
|
91
|
+
// fluxo falhar depois da geração.
|
|
92
|
+
const usageEntries = [];
|
|
93
|
+
try {
|
|
94
|
+
console.log(`Gerando spec.md para: ${issue.title}`);
|
|
95
|
+
const { content, lintFindings } = await generateDocument(SYSTEM_PROMPT, userContent, {
|
|
96
|
+
action: 'spec',
|
|
97
|
+
lint: { lang: TARGET_LANGUAGE },
|
|
98
|
+
withReport: true,
|
|
99
|
+
usage: usageEntries,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
mkdirSync(featureDir, { recursive: true });
|
|
103
|
+
writeFileSync(filePath, content, 'utf-8');
|
|
104
|
+
|
|
105
|
+
// Commit and push
|
|
106
|
+
const git = (cmd) => execSync(cmd, { stdio: 'inherit' });
|
|
107
|
+
git(`git config user.email "spec-wave[bot]@github.com"`);
|
|
108
|
+
git(`git config user.name "spec-wave[bot]"`);
|
|
109
|
+
git(`git add "${filePath}"`);
|
|
110
|
+
git(`git commit -m "docs: generate spec.md for ${slug} [spec-wave]"`);
|
|
111
|
+
git('git pull --rebase');
|
|
112
|
+
git('git push');
|
|
113
|
+
|
|
114
|
+
// Remove trigger label
|
|
115
|
+
await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:spec');
|
|
116
|
+
|
|
117
|
+
// Comment on issue
|
|
118
|
+
await commentOnIssue(
|
|
119
|
+
token, owner, repo, parseInt(issueNumber, 10),
|
|
120
|
+
`📋 **spec.md gerado automaticamente!**\n\n` +
|
|
121
|
+
`📄 Arquivo: [\`${filePath}\`](https://github.com/${owner}/${repo}/blob/main/${filePath})\n\n` +
|
|
122
|
+
`Revise a especificação e, quando estiver pronto, gere o plano técnico: mova o card para **📋 Plan** ou use:\n` +
|
|
123
|
+
`\`\`\`\ngh issue edit ${issueNumber} --add-label "spec-wave:plan"\n\`\`\`` +
|
|
124
|
+
formatLintWarning(lintFindings)
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
console.log(`spec.md criado em: ${filePath}`);
|
|
128
|
+
} finally {
|
|
129
|
+
// Best-effort: nunca propaga erro (ver recordUsage).
|
|
130
|
+
await recordUsage({ token, owner, repo, issueNumber: parseInt(issueNumber, 10), entries: usageEntries });
|
|
131
|
+
}
|
|
120
132
|
}
|
package/src/commands/info.mjs
CHANGED
|
@@ -3,20 +3,57 @@ import chalk from 'chalk';
|
|
|
3
3
|
import { readFileSync, existsSync } from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { CONFIG_FILE, PORTAL_URL } from '../config.mjs';
|
|
6
|
+
import { skillStatus } from './install-skill.mjs';
|
|
7
|
+
|
|
8
|
+
// Resume o estado da skill para a saída JSON. `null` = não foi possível checar.
|
|
9
|
+
function skillJson(status) {
|
|
10
|
+
if (!status) return null;
|
|
11
|
+
return {
|
|
12
|
+
agentsDetected: status.agentsDetected,
|
|
13
|
+
installNeeded: status.agentsDetected.length === 0 || status.pending.length > 0,
|
|
14
|
+
pending: status.pending,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Reporta (saída humana) se o usuário precisa rodar o install-skill: nenhum
|
|
19
|
+
// agente detectado → não dá para validar, sugere instalar; cópia ausente ou
|
|
20
|
+
// de versão antiga → aponta o agente e o motivo.
|
|
21
|
+
function reportSkill(status) {
|
|
22
|
+
if (!status) return;
|
|
23
|
+
if (status.agentsDetected.length === 0) {
|
|
24
|
+
p.log.warn(
|
|
25
|
+
'Nenhum agente de código detectado neste diretório — a skill spec-wave ' +
|
|
26
|
+
'não parece instalada. Rode `npx @spec-wave/cli install-skill`.'
|
|
27
|
+
);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (status.pending.length === 0) {
|
|
31
|
+
p.log.success(`Skill instalada e atualizada (${status.agentsDetected.join(', ')}).`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
p.log.warn(
|
|
35
|
+
'Skill pendente de instalação/atualização:\n' +
|
|
36
|
+
status.pending.map(s => ` ${chalk.yellow('↻')} ${s.agent} (${s.reason}) — ${chalk.dim(s.path)}`).join('\n') +
|
|
37
|
+
'\nRode `npx @spec-wave/cli install-skill` (ou `npx @spec-wave/cli update`).'
|
|
38
|
+
);
|
|
39
|
+
}
|
|
6
40
|
|
|
7
41
|
// Lê o marcador .spec-wave.json do repositório atual (cwd) e reporta se o
|
|
8
42
|
// spec-wave já foi inicializado. Usado pela skill para decidir entre mostrar
|
|
9
|
-
// as informações ou oferecer rodar o `init`.
|
|
43
|
+
// as informações ou oferecer rodar o `init`. Também valida se a skill instalada
|
|
44
|
+
// nos agentes detectados está em dia com a versão empacotada na CLI.
|
|
10
45
|
export async function info(options = {}) {
|
|
11
46
|
const configPath = path.join(process.cwd(), CONFIG_FILE);
|
|
47
|
+
const skill = skillStatus();
|
|
12
48
|
|
|
13
49
|
if (!existsSync(configPath)) {
|
|
14
50
|
if (options.json) {
|
|
15
|
-
console.log(JSON.stringify({ initialized: false }));
|
|
51
|
+
console.log(JSON.stringify({ initialized: false, skill: skillJson(skill) }));
|
|
16
52
|
return;
|
|
17
53
|
}
|
|
18
54
|
p.intro(chalk.bold('spec-wave info'));
|
|
19
55
|
p.log.warn(`Este repositório ${chalk.bold('não foi inicializado')} (sem ${CONFIG_FILE}).`);
|
|
56
|
+
reportSkill(skill);
|
|
20
57
|
p.log.info(`🌐 Acesse o Portal Web da ferramenta em ${chalk.cyan(PORTAL_URL)}`);
|
|
21
58
|
p.outro('Execute `npx @spec-wave/cli init` para configurar.');
|
|
22
59
|
return;
|
|
@@ -27,7 +64,7 @@ export async function info(options = {}) {
|
|
|
27
64
|
config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
28
65
|
} catch (err) {
|
|
29
66
|
if (options.json) {
|
|
30
|
-
console.log(JSON.stringify({ initialized: false, error: err.message }));
|
|
67
|
+
console.log(JSON.stringify({ initialized: false, error: err.message, skill: skillJson(skill) }));
|
|
31
68
|
return;
|
|
32
69
|
}
|
|
33
70
|
p.log.error(`${CONFIG_FILE} existe mas está corrompido: ${err.message}`);
|
|
@@ -36,7 +73,7 @@ export async function info(options = {}) {
|
|
|
36
73
|
}
|
|
37
74
|
|
|
38
75
|
if (options.json) {
|
|
39
|
-
console.log(JSON.stringify({ initialized: true, ...config }));
|
|
76
|
+
console.log(JSON.stringify({ initialized: true, ...config, skill: skillJson(skill) }));
|
|
40
77
|
return;
|
|
41
78
|
}
|
|
42
79
|
|
|
@@ -51,6 +88,7 @@ export async function info(options = {}) {
|
|
|
51
88
|
`${chalk.dim('Criado em:')} ${config.initializedAt ?? '?'}`,
|
|
52
89
|
'Configuração'
|
|
53
90
|
);
|
|
91
|
+
reportSkill(skill);
|
|
54
92
|
p.log.info(`🌐 Acesse o Portal Web da ferramenta em ${chalk.cyan(PORTAL_URL)}`);
|
|
55
93
|
p.outro('Use `/spec-wave feature <descrição>` para criar uma Feature.');
|
|
56
94
|
}
|
|
@@ -178,6 +178,46 @@ export function isDetected(target, baseDir) {
|
|
|
178
178
|
return target.detect.some((sig) => existsSync(path.join(baseDir, sig)));
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
// Motivo pelo qual a cópia da skill em `dest` precisa ser (re)instalada:
|
|
182
|
+
// 'ausente' | 'bloco ausente' | 'desatualizada' — ou null se está em dia com a
|
|
183
|
+
// versão empacotada na CLI. Compartilhado entre `update` e `info`.
|
|
184
|
+
export function skillCopyReason(dest, parsed) {
|
|
185
|
+
const desired = renderContent(dest.format, parsed, CLI_VERSION);
|
|
186
|
+
const existing = existsSync(dest.path) ? readFileSync(dest.path, 'utf-8') : null;
|
|
187
|
+
if (existing === null) return 'ausente';
|
|
188
|
+
if (dest.format === 'agents') {
|
|
189
|
+
const block = extractAgentsBlock(existing);
|
|
190
|
+
if (block === null) return 'bloco ausente';
|
|
191
|
+
return block.trim() !== desired.trim() ? 'desatualizada' : null;
|
|
192
|
+
}
|
|
193
|
+
return existing !== desired ? 'desatualizada' : null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Estado da skill para os agentes detectados em cwd (escopo projeto), com
|
|
197
|
+
// fallback: uma cópia GLOBAL atualizada atende o agente mesmo sem cópia local.
|
|
198
|
+
// Retorna null quando a fonte da skill não está no pacote (instalação parcial).
|
|
199
|
+
export function skillStatus(cwd = process.cwd()) {
|
|
200
|
+
if (!existsSync(SKILL_SOURCE)) return null;
|
|
201
|
+
const parsed = parseSkill(readFileSync(SKILL_SOURCE, 'utf-8'));
|
|
202
|
+
const agentsDetected = [];
|
|
203
|
+
const pending = [];
|
|
204
|
+
for (const target of TARGETS) {
|
|
205
|
+
if (!isDetected(target, cwd)) continue;
|
|
206
|
+
agentsDetected.push(target.name);
|
|
207
|
+
const dest = resolveDest(target, cwd, false);
|
|
208
|
+
if (!dest) continue;
|
|
209
|
+
let reason = skillCopyReason(dest, parsed);
|
|
210
|
+
if (reason === 'ausente') {
|
|
211
|
+
const globalDest = resolveDest(target, homedir(), true);
|
|
212
|
+
if (globalDest && skillCopyReason(globalDest, parsed) === null) reason = null;
|
|
213
|
+
}
|
|
214
|
+
if (reason) {
|
|
215
|
+
pending.push({ agent: target.name, key: target.key, path: dest.path, reason });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return { agentsDetected, pending };
|
|
219
|
+
}
|
|
220
|
+
|
|
181
221
|
export async function installSkill(options = {}) {
|
|
182
222
|
p.intro(chalk.bold('spec-wave install-skill'));
|
|
183
223
|
|
package/src/commands/update.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
} from '../api/github-rest.mjs';
|
|
13
13
|
import {
|
|
14
14
|
TARGETS, SKILL_SOURCE, CLI_VERSION, parseSkill, renderContent,
|
|
15
|
-
mergeAgentsFile, resolveDest, isDetected,
|
|
15
|
+
mergeAgentsFile, resolveDest, isDetected, skillCopyReason,
|
|
16
16
|
} from './install-skill.mjs';
|
|
17
17
|
|
|
18
18
|
const __dir = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -35,19 +35,10 @@ function detectSkill(parsed, baseDir, isGlobal) {
|
|
|
35
35
|
if (!isDetected(target, baseDir)) continue;
|
|
36
36
|
const dest = resolveDest(target, baseDir, isGlobal);
|
|
37
37
|
if (!dest) continue;
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (existing === null) {
|
|
42
|
-
reason = 'ausente';
|
|
43
|
-
} else if (dest.format === 'agents') {
|
|
44
|
-
const block = extractAgentsBlock(existing);
|
|
45
|
-
if (block === null) reason = 'bloco ausente';
|
|
46
|
-
else if (block.trim() !== desired.trim()) reason = 'desatualizada';
|
|
47
|
-
} else if (existing !== desired) {
|
|
48
|
-
reason = 'desatualizada';
|
|
38
|
+
const reason = skillCopyReason(dest, parsed);
|
|
39
|
+
if (reason) {
|
|
40
|
+
jobs.push({ target, dest, desired: renderContent(dest.format, parsed, CLI_VERSION), reason });
|
|
49
41
|
}
|
|
50
|
-
if (reason) jobs.push({ target, dest, desired, reason });
|
|
51
42
|
}
|
|
52
43
|
return jobs;
|
|
53
44
|
}
|
package/src/config.mjs
CHANGED
|
@@ -68,6 +68,7 @@ export const STATUS_OPTIONS = [
|
|
|
68
68
|
// atual. Ao avançar de etapa, o Status reinicia em "Todo".
|
|
69
69
|
|
|
70
70
|
// Etapas (campo Etapa) referenciadas pelo fluxo de implementação.
|
|
71
|
+
export const STAGE_READY = STATUS_OPTIONS.find(s => s.name.includes('Ready')).name;
|
|
71
72
|
export const STAGE_DEVELOPMENT = STATUS_OPTIONS.find(s => s.name.includes('Desenvolvimento')).name;
|
|
72
73
|
export const STAGE_CODE_REVIEW = STATUS_OPTIONS.find(s => s.name.includes('Code Review')).name;
|
|
73
74
|
export const STAGE_DONE = STATUS_OPTIONS.find(s => s.name.includes('Done')).name;
|
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
|
+
}
|
|
@@ -137,7 +137,9 @@ Mesmas flags do `issue` (exceto `--type`, fixo em `feature`). Mantido para o flu
|
|
|
137
137
|
### `@spec-wave/cli info` — status de configuração do repo atual
|
|
138
138
|
| Flag | Tipo | Descrição |
|
|
139
139
|
|------|------|-----------|
|
|
140
|
-
| `--json` | flag | Saída JSON (`{"initialized":bool, ...}`) para parsing programático. |
|
|
140
|
+
| `--json` | flag | Saída JSON (`{"initialized":bool, ..., "skill":{...}}`) para parsing programático. |
|
|
141
|
+
|
|
142
|
+
> Além do `.spec-wave.json`, valida a **skill instalada**: para cada agente detectado no diretório, compara a cópia instalada com a versão empacotada na CLI (uma cópia **global** atualizada também conta). No JSON, o campo `skill` traz `{agentsDetected, installNeeded, pending:[{agent, reason, path}]}` — `installNeeded: true` significa que o usuário precisa rodar `install-skill` (ou `update`).
|
|
141
143
|
|
|
142
144
|
### `@spec-wave/cli refresh` — atualiza o `.spec-wave.json` local
|
|
143
145
|
| Flag | Tipo | Descrição |
|
|
@@ -177,7 +179,7 @@ Mesmas flags do `issue` (exceto `--type`, fixo em `feature`). Mantido para o flu
|
|
|
177
179
|
> Diferente dos quatro acima, `implement` roda **localmente** (lê `.spec-wave.json`, como `issue`), não por Action. Detecta o tipo da issue: **Story** → coleta todas as Tasks (sub-issues) e aciona o spec-kit uma única vez; **Task** → só aquela task. Monta o contexto em `.spec-wave/implement-<n>.md` e chama o comando configurado em `specKit.command` (no `.spec-wave.json`) ou na env `SPEC_WAVE_IMPLEMENT_CMD`. Placeholders disponíveis no template: `{tasksFile} {specFile} {planFile} {issue} {type} {title}`. Se nada estiver configurado, ele apenas monta o contexto e mostra como configurar (não executa). O contexto inclui os **comentários da issue**, um **digest do código recente** e um **aviso de dependências pendentes** quando a issue depende (linha `Depende de: #N` ou relação nativa *blocked by*) de outra que ainda não foi concluída — nesse caso, confirme com o usuário antes de seguir. Inclui também instruções para o agente implementar as Tasks **sequencialmente, uma por vez** (nunca duas com Status "In Progress" ao mesmo tempo): cada Task usa o **Status** (In Progress) *dentro* da Etapa 🚧 Desenvolvimento e, **ao concluir, avança para a Etapa 🎉 Done com Status Done**. **Ao concluir toda a Story**: fazer o commit, abrir o PR e **avançar a Etapa da Story para 👀 Code Review** (Status → Todo) — as Tasks já estão em 🎉 Done. A **Feature só avança** para Code Review quando **TODAS as suas Stories** já estiverem em Code Review — enquanto houver Story pendente, a Feature fica em 🚧 Desenvolvimento. Etapa só avança (nunca volta); Status mede o progresso dentro da etapa.
|
|
178
180
|
|
|
179
181
|
### `@spec-wave/cli doctor` — preflight de auth e configuração (comando LOCAL)
|
|
180
|
-
Sem flags. Roda um checklist de diagnóstico no repositório atual: token GitHub (e a fonte dele), escopos (`repo`, `project`, `workflow` — com degradação para checks funcionais em fine-grained PATs), conta ativa do `gh` vs. owner, `.spec-wave.json` (campos e sincronia com o Project real), acesso ao repositório, configuração de IA (provider/modelo/`ai.models` + secrets do Actions) e presença dos workflows.
|
|
182
|
+
Sem flags. Roda um checklist de diagnóstico no repositório atual: token GitHub (e a fonte dele), escopos (`repo`, `project`, `workflow` — com degradação para checks funcionais em fine-grained PATs), conta ativa do `gh` vs. owner, `.spec-wave.json` (campos e sincronia com o Project real), acesso ao repositório, configuração de IA (provider/modelo/`ai.models` + secrets do Actions), **spec-kit** (`specKit.command` / env `SPEC_WAVE_IMPLEMENT_CMD` — se ausente, avisa e sugere exemplos por agente: Claude Code, opencode, Codex, Copilot CLI, Kiro CLI, Qwen Code) e presença dos workflows.
|
|
181
183
|
|
|
182
184
|
> Saída: `✓` ok, `✗` problema confirmado, `!` não verificável (best-effort — falha de rede nunca derruba o doctor). **Exit 1** se houver algum `✗`. **Quando rodar:** no início de uma sessão de trabalho, ou sempre que aparecer um erro estranho (ex.: **404 ao criar issues** — causa típica: token sem acesso ao repo/org, que o doctor aponta). É o primeiro passo de troubleshooting — prefira-o a depurar `gh api` na mão.
|
|
183
185
|
|
|
@@ -263,6 +265,7 @@ Mostra se o repositório atual já foi configurado com o spec-wave.
|
|
|
263
265
|
3. **Se NÃO estiver inicializado**, pergunte ao usuário: "Este repositório ainda não foi configurado com o spec-wave. Quer rodar o `init` agora?"
|
|
264
266
|
- Se sim → siga o fluxo de `/spec-wave setup`.
|
|
265
267
|
- Se não → encerre sem alterar nada.
|
|
268
|
+
4. **Se a saída indicar skill pendente** (aviso "Skill pendente de instalação/atualização" ou, no `--json`, `skill.installNeeded: true`), pergunte ao usuário se quer instalar/atualizar agora: skill `ausente` → `npx @spec-wave/cli install-skill`; skill `desatualizada` → `npx @spec-wave/cli update` (atualiza tudo que ficou para trás). Lembre-o de recarregar o agente depois.
|
|
266
269
|
|
|
267
270
|
---
|
|
268
271
|
|
|
@@ -470,7 +473,7 @@ Para qualquer outro tipo (Spike, Bug, Story, Task, …) o Action **recusa** e co
|
|
|
470
473
|
gh issue edit <número> --add-label "spec-wave:decompose"
|
|
471
474
|
```
|
|
472
475
|
3. Informe: "Decomposição iniciada — Feature gera Stories+Tasks; RFC gera Tasks."
|
|
473
|
-
4. Após a conclusão, as issues filhas aparecerão como comentário na issue pai, junto com o comentário 🔎 da crítica adversarial. As Stories geradas trazem a linha `Depende de: #N` (+ relação *blocked by*) — use `npx @spec-wave/cli order <número>` para ver a ordem de execução.
|
|
476
|
+
4. Após a conclusão, as issues filhas aparecerão como comentário na issue pai, junto com o comentário 🔎 da crítica adversarial. A issue pai e as Stories/Tasks criadas entram no board na Etapa **✅ Ready** (Status Todo; a Etapa nunca retrocede — itens já adiante não são tocados). As Stories geradas trazem a linha `Depende de: #N` (+ relação *blocked by*) — use `npx @spec-wave/cli order <número>` para ver a ordem de execução.
|
|
474
477
|
5. A issue recebe a label `spec-wave:decomposed` (guard de idempotência): rodar de novo **não** duplica as issues. Para forçar um re-decompose, siga a seção *Guard de idempotência*.
|
|
475
478
|
|
|
476
479
|
---
|