@spec-wave/cli 0.23.0 → 0.25.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/README.md +109 -5
- package/bin/spec-wave.mjs +35 -2
- package/package.json +1 -1
- package/src/agent/anthropic-agent.mjs +40 -19
- package/src/agent/index.mjs +104 -38
- package/src/api/github-rest.mjs +63 -1
- package/src/commands/decompose.mjs +3 -10
- package/src/commands/doctor.mjs +70 -12
- package/src/commands/generate-bug.mjs +8 -14
- package/src/commands/generate-plan.mjs +2 -1
- package/src/commands/generate-spec.mjs +2 -1
- package/src/commands/init.mjs +5 -0
- package/src/commands/mode.mjs +169 -0
- package/src/commands/run.mjs +396 -0
- package/src/commands/validate.mjs +22 -6
- package/src/config.mjs +40 -6
- package/src/lib/claude.mjs +13 -3
- package/src/lib/config-file.mjs +62 -0
- package/src/lib/doc-paths.mjs +51 -0
- package/src/lib/execution-mode.mjs +110 -0
- package/src/lib/next-step.mjs +356 -0
- package/src/lib/pr-step.mjs +102 -0
- package/src/lib/repo-links.mjs +84 -0
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/skills/doctor/SKILL.md +2 -1
- package/src/plugin/skills/run/SKILL.md +76 -0
- package/src/plugin/skills/setup/SKILL.md +1 -1
- package/src/plugin/skills/spec/SKILL.md +1 -1
- package/src/templates/skill/SKILL.md +25 -2
- package/src/templates/workflows/code-review.yml +5 -1
- package/src/templates/workflows/critique.yml +5 -0
- package/src/templates/workflows/decompose.yml +5 -0
- package/src/templates/workflows/generate-bug.yml +5 -0
- package/src/templates/workflows/generate-plan.yml +5 -0
- package/src/templates/workflows/generate-spec.yml +5 -0
- package/src/templates/workflows/qa.yml +6 -1
- package/src/templates/workflows/validate.yml +4 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// Executa LOCALMENTE o passo que a label dispararia no GitHub Actions.
|
|
2
|
+
//
|
|
3
|
+
// O YAML nunca fez trabalho nenhum: ele instala a CLI e chama um comando. Este
|
|
4
|
+
// comando fecha o buraco que sobra quando os workflows estão desarmados
|
|
5
|
+
// (`spec-wave mode local`) — decidir QUAL comando é o próximo e chamá-lo, sem
|
|
6
|
+
// aplicar label nenhuma.
|
|
7
|
+
//
|
|
8
|
+
// A decisão é uma função pura (`lib/next-step.mjs`), testada sem rede. Aqui só
|
|
9
|
+
// mora o que é impuro: coletar o estado, segurar o lock, despachar e relatar.
|
|
10
|
+
//
|
|
11
|
+
// Três coisas que este comando NUNCA faz, e o porquê:
|
|
12
|
+
// • aplicar label de gatilho — dispararia o Action e a execução aconteceria duas vezes;
|
|
13
|
+
// • rodar dentro do Actions — lá o contrato é a label (`assertNotInActions`);
|
|
14
|
+
// • encadear passos sem teto — cada passo de IA custa dinheiro (`--max-steps`).
|
|
15
|
+
|
|
16
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs';
|
|
17
|
+
import { execSync } from 'node:child_process';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
import chalk from 'chalk';
|
|
21
|
+
|
|
22
|
+
import { resolveToken } from '../api/auth.mjs';
|
|
23
|
+
import { getIssue, getPR, getFileContent, listPullRequestReviews } from '../api/github-rest.mjs';
|
|
24
|
+
import { loadProjectConfig } from '../lib/board.mjs';
|
|
25
|
+
import { existsOnRemote } from '../lib/doc-availability.mjs';
|
|
26
|
+
import { featureDocPaths, bugDocPaths } from '../lib/doc-paths.mjs';
|
|
27
|
+
import { isActionsRun, resolveFlowContext } from '../lib/flow-run.mjs';
|
|
28
|
+
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
29
|
+
import { nextStep, resolveForcedStep, STEPS } from '../lib/next-step.mjs';
|
|
30
|
+
import { nextPrStep, reviewVerdict } from '../lib/pr-step.mjs';
|
|
31
|
+
import { configuredMode } from '../lib/execution-mode.mjs';
|
|
32
|
+
import { labelNames } from '../config.mjs';
|
|
33
|
+
|
|
34
|
+
const LOCK_STALE_MS = 30 * 60 * 1000;
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Lock — o substituto do `concurrency: spec-wave-item-<n>` dos workflows.
|
|
38
|
+
//
|
|
39
|
+
// Sem ele, dois `run` na mesma issue geram dois documentos, dois commits
|
|
40
|
+
// disputando a ponta da branch e leitura-modificação-escrita concorrente do
|
|
41
|
+
// comentário de uso. Fica em .git/ porque já é ignorado e é por clone.
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
function lockPath(root, key) {
|
|
45
|
+
return path.join(root || process.cwd(), '.git', 'spec-wave', `run-${key}.lock`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function acquireLock(root, key) {
|
|
49
|
+
const file = lockPath(root, key);
|
|
50
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
51
|
+
const payload = JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() });
|
|
52
|
+
try {
|
|
53
|
+
writeFileSync(file, payload, { flag: 'wx' });
|
|
54
|
+
return file;
|
|
55
|
+
} catch (err) {
|
|
56
|
+
if (err.code !== 'EEXIST') throw err;
|
|
57
|
+
let idade = Infinity;
|
|
58
|
+
let dono = null;
|
|
59
|
+
try {
|
|
60
|
+
dono = JSON.parse(readFileSync(file, 'utf-8'));
|
|
61
|
+
idade = Date.now() - Date.parse(dono.startedAt);
|
|
62
|
+
} catch { /* lock ilegível conta como velho */ }
|
|
63
|
+
if (idade < LOCK_STALE_MS) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`Já existe um \`spec-wave run\` para ${key} (pid ${dono?.pid ?? '?'}, desde ${dono?.startedAt ?? '?'}).\n` +
|
|
66
|
+
`Se tiver certeza de que morreu, apague ${file}.`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
console.warn(`⚠️ Lock antigo encontrado (${file}) — assumindo processo morto e seguindo.`);
|
|
70
|
+
writeFileSync(file, payload);
|
|
71
|
+
return file;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function releaseLock(file) {
|
|
76
|
+
try {
|
|
77
|
+
if (file) unlinkSync(file);
|
|
78
|
+
} catch { /* já removido */ }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Commits locais ainda não publicados: gerar o próximo documento por cima de um
|
|
82
|
+
// anterior que ficou só no clone é execução parcial disfarçada de sucesso.
|
|
83
|
+
function unpushedCommits(root) {
|
|
84
|
+
try {
|
|
85
|
+
const out = execSync('git rev-list --count @{u}..HEAD', {
|
|
86
|
+
cwd: root || process.cwd(), encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
87
|
+
}).trim();
|
|
88
|
+
return Number.parseInt(out, 10) || 0;
|
|
89
|
+
} catch {
|
|
90
|
+
return 0; // sem upstream configurado: não dá para afirmar nada
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function assertNotInActions() {
|
|
95
|
+
if (!isActionsRun()) return;
|
|
96
|
+
throw new Error(
|
|
97
|
+
'O `run` é o gatilho do modo LOCAL — dentro do GitHub Actions o gatilho é a label, ' +
|
|
98
|
+
'e o workflow já chama o comando certo. Rodar os dois seria executar o passo duas vezes.'
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Coleta do estado
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
function localDocStates(issue, type, root) {
|
|
107
|
+
if (type === 'Bug') {
|
|
108
|
+
const { fileRel, fileAbs } = bugDocPaths(issue.title, root);
|
|
109
|
+
return {
|
|
110
|
+
docs: { bug: existsSync(fileAbs) ? 'local' : 'missing' },
|
|
111
|
+
docPaths: { bug: fileRel },
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const paths = featureDocPaths(root, issue, type);
|
|
115
|
+
const docs = {};
|
|
116
|
+
const docPaths = {};
|
|
117
|
+
for (const nome of ['spec', 'plan', 'decomposition']) {
|
|
118
|
+
docs[nome] = existsSync(paths[nome].abs) ? 'local' : 'missing';
|
|
119
|
+
docPaths[nome] = paths[nome].rel;
|
|
120
|
+
}
|
|
121
|
+
return { docs, docPaths };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Sonda o remoto SÓ pelos documentos que o passo escolhido usa e que faltam no
|
|
126
|
+
* clone. É a diferença entre "ainda não foi gerado" e "foi gerado e você não
|
|
127
|
+
* puxou" — a segunda leva a sobrescrever trabalho publicado.
|
|
128
|
+
*/
|
|
129
|
+
async function probeRemote({ decision, docs, docPaths, token, owner, repo }) {
|
|
130
|
+
const step = STEPS[decision.action];
|
|
131
|
+
if (!step) return docs;
|
|
132
|
+
const alvos = [step.writes, ...step.reads]
|
|
133
|
+
.filter(Boolean)
|
|
134
|
+
.filter(doc => docs[doc] === 'missing');
|
|
135
|
+
if (alvos.length === 0) return docs;
|
|
136
|
+
|
|
137
|
+
const atualizado = { ...docs };
|
|
138
|
+
for (const doc of alvos) {
|
|
139
|
+
const onRemote = await existsOnRemote({
|
|
140
|
+
getFileContent, token, owner, repo, pathRel: docPaths[doc],
|
|
141
|
+
});
|
|
142
|
+
if (onRemote === true) atualizado[doc] = 'remote';
|
|
143
|
+
else if (onRemote === null) atualizado[doc] = 'unknown';
|
|
144
|
+
}
|
|
145
|
+
return atualizado;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Relato
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
function reportDecision(decision, { issueNumber, type, title, warnings }) {
|
|
153
|
+
console.log(chalk.bold(`\nIssue #${issueNumber} · ${type || 'tipo desconhecido'} · ${title}`));
|
|
154
|
+
console.log(`Próximo passo: ${chalk.cyan(decision.action)}`);
|
|
155
|
+
console.log(`Motivo: ${decision.reason}`);
|
|
156
|
+
if (decision.command) console.log(`Comando: ${chalk.dim(decision.command)}`);
|
|
157
|
+
for (const aviso of warnings) console.warn(chalk.yellow(`⚠️ ${aviso}`));
|
|
158
|
+
if (decision.blocked) {
|
|
159
|
+
console.log(chalk.yellow(`\n⛔ ${decision.blocked.code}: ${decision.blocked.message}`));
|
|
160
|
+
console.log(` ${decision.blocked.unblock}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// O prefixo do título é o que os workflows filtram (`contains(title, '[FEATURE]')`),
|
|
165
|
+
// enquanto detectIssueType tem fallback por label: sem o aviso, uma issue rodaria
|
|
166
|
+
// local e nunca rodaria no runner, e a diferença só apareceria ao voltar o modo.
|
|
167
|
+
function parityWarnings(issue, type) {
|
|
168
|
+
const avisos = [];
|
|
169
|
+
const titulo = String(issue.title || '');
|
|
170
|
+
if (type && !titulo.includes(`[${type.toUpperCase()}]`)) {
|
|
171
|
+
avisos.push(
|
|
172
|
+
`O título não traz o prefixo [${type.toUpperCase()}] — o workflow correspondente NÃO ` +
|
|
173
|
+
'dispararia para esta issue no modo actions (ele filtra pelo título).'
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return avisos;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Despacho
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
async function dispatch(action, { issueNumber }) {
|
|
184
|
+
switch (action) {
|
|
185
|
+
case 'generate-spec': {
|
|
186
|
+
const { generateSpec } = await import('./generate-spec.mjs');
|
|
187
|
+
return await generateSpec({ issueNumber });
|
|
188
|
+
}
|
|
189
|
+
case 'generate-plan': {
|
|
190
|
+
const { generatePlan } = await import('./generate-plan.mjs');
|
|
191
|
+
return await generatePlan({ issueNumber });
|
|
192
|
+
}
|
|
193
|
+
case 'critique': {
|
|
194
|
+
const { critique } = await import('./generate-plan.mjs');
|
|
195
|
+
return await critique({ issueNumber });
|
|
196
|
+
}
|
|
197
|
+
case 'validate': {
|
|
198
|
+
const { validate } = await import('./validate.mjs');
|
|
199
|
+
return await validate({ issueNumber });
|
|
200
|
+
}
|
|
201
|
+
case 'decompose': {
|
|
202
|
+
const { decompose } = await import('./decompose.mjs');
|
|
203
|
+
return await decompose({ issueNumber, apply: false });
|
|
204
|
+
}
|
|
205
|
+
case 'decompose-apply': {
|
|
206
|
+
const { decompose } = await import('./decompose.mjs');
|
|
207
|
+
return await decompose({ issueNumber, apply: true });
|
|
208
|
+
}
|
|
209
|
+
case 'generate-bug': {
|
|
210
|
+
const { generateBug } = await import('./generate-bug.mjs');
|
|
211
|
+
return await generateBug({ issueNumber });
|
|
212
|
+
}
|
|
213
|
+
default:
|
|
214
|
+
throw new Error(`Passo sem despacho: ${action}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// Modo PR
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
async function runForPr({ prNumber, dryRun, yes, only, json }) {
|
|
223
|
+
const { owner, repo, root } = resolveFlowContext({ command: 'run --pr' });
|
|
224
|
+
const token = await resolveToken();
|
|
225
|
+
const n = parseInt(prNumber, 10);
|
|
226
|
+
|
|
227
|
+
const pr = await getPR(token, owner, repo, n);
|
|
228
|
+
const reviews = await listPullRequestReviews(token, owner, repo, n);
|
|
229
|
+
const verdict = reviewVerdict(reviews);
|
|
230
|
+
const decision = nextPrStep({
|
|
231
|
+
state: pr.state,
|
|
232
|
+
draft: Boolean(pr.draft),
|
|
233
|
+
merged: Boolean(pr.merged_at),
|
|
234
|
+
approved: verdict.approved,
|
|
235
|
+
changesRequestedAfterApproval: verdict.changesRequestedAfterApproval,
|
|
236
|
+
only: only || null,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const { error: boardError } = loadProjectConfig({ cwd: root || process.cwd() });
|
|
240
|
+
if (boardError) decision.warnings.push(`${boardError} — o board não será atualizado.`);
|
|
241
|
+
|
|
242
|
+
if (json) {
|
|
243
|
+
console.log(JSON.stringify({ pr: n, ...decision, approvers: verdict.approvers }, null, 2));
|
|
244
|
+
} else {
|
|
245
|
+
console.log(chalk.bold(`\nPR #${n} · ${pr.title}`));
|
|
246
|
+
console.log(`Passos: ${decision.steps.length ? chalk.cyan(decision.steps.join(' → ')) : '(nenhum)'}`);
|
|
247
|
+
console.log(`Motivo: ${decision.reason}`);
|
|
248
|
+
for (const aviso of decision.warnings) console.warn(chalk.yellow(`⚠️ ${aviso}`));
|
|
249
|
+
if (decision.blocked) console.log(chalk.yellow(`\n⛔ ${decision.blocked.code}: ${decision.blocked.message}`));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (decision.blocked || decision.steps.length === 0) {
|
|
253
|
+
process.exitCode = decision.blocked ? 2 : 0;
|
|
254
|
+
return decision;
|
|
255
|
+
}
|
|
256
|
+
if (verdict.changesRequestedAfterApproval && !yes) {
|
|
257
|
+
console.log(chalk.yellow('\n⛔ needs-confirmation: há pedido de mudanças além da aprovação. Confirme com `--yes`.'));
|
|
258
|
+
process.exitCode = 2;
|
|
259
|
+
return decision;
|
|
260
|
+
}
|
|
261
|
+
if (dryRun) {
|
|
262
|
+
console.log(chalk.dim('\nDry-run: nada foi executado.'));
|
|
263
|
+
return decision;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const lock = acquireLock(root, `pr-${n}`);
|
|
267
|
+
try {
|
|
268
|
+
for (const step of decision.steps) {
|
|
269
|
+
console.log(chalk.bold(`\n▶ ${step} --pr-number ${n}`));
|
|
270
|
+
if (step === 'code-review') {
|
|
271
|
+
const { codeReview } = await import('./code-review.mjs');
|
|
272
|
+
await codeReview({ prNumber: String(n) });
|
|
273
|
+
} else {
|
|
274
|
+
const { qa } = await import('./qa.mjs');
|
|
275
|
+
await qa({ prNumber: String(n) });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
} finally {
|
|
279
|
+
releaseLock(lock);
|
|
280
|
+
}
|
|
281
|
+
return decision;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// Comando
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
export async function run(issueArg, options = {}) {
|
|
289
|
+
assertNotInActions();
|
|
290
|
+
|
|
291
|
+
const {
|
|
292
|
+
pr, dryRun = false, yes = false, apply = false, step: forced = null,
|
|
293
|
+
maxSteps = 1, force = false, remoteCheck = true, json = false, only = null,
|
|
294
|
+
} = options;
|
|
295
|
+
|
|
296
|
+
if (pr) return await runForPr({ prNumber: pr, dryRun, yes, only, json });
|
|
297
|
+
|
|
298
|
+
if (!issueArg) throw new Error('Informe o número da issue: `spec-wave run <issue>` (ou `--pr <n>`).');
|
|
299
|
+
|
|
300
|
+
const { owner, repo, root, config } = resolveFlowContext({ command: 'run' });
|
|
301
|
+
const token = await resolveToken();
|
|
302
|
+
const issueNumber = String(issueArg).replace(/^#/, '');
|
|
303
|
+
const teto = Math.max(1, parseInt(maxSteps, 10) || 1);
|
|
304
|
+
|
|
305
|
+
if (configuredMode(config) !== 'local') {
|
|
306
|
+
console.warn(chalk.yellow(
|
|
307
|
+
'⚠️ Este repositório está em modo `actions` — os workflows continuam armados e podem ' +
|
|
308
|
+
'rodar o mesmo passo. Use `spec-wave mode local` para desarmá-los.'
|
|
309
|
+
));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const lock = acquireLock(root, issueNumber);
|
|
313
|
+
// `critiqueFile` seta process.exitCode = 1; sem preservar, um run inteiro
|
|
314
|
+
// bem-sucedido sairia com código de erro.
|
|
315
|
+
const exitCodeAntes = process.exitCode;
|
|
316
|
+
try {
|
|
317
|
+
let ultimaDecisao = null;
|
|
318
|
+
|
|
319
|
+
for (let i = 0; i < teto; i++) {
|
|
320
|
+
const issue = await getIssue(token, owner, repo, parseInt(issueNumber, 10));
|
|
321
|
+
const type = detectIssueType(issue);
|
|
322
|
+
const { docs, docPaths } = localDocStates(issue, type, root);
|
|
323
|
+
const { error: boardError } = loadProjectConfig({ cwd: root || process.cwd() });
|
|
324
|
+
|
|
325
|
+
let forcedStep = null;
|
|
326
|
+
if (forced && i === 0) {
|
|
327
|
+
const resolvido = resolveForcedStep(forced, { type });
|
|
328
|
+
if (resolvido.error) throw new Error(resolvido.error);
|
|
329
|
+
forcedStep = resolvido.action;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const entrada = {
|
|
333
|
+
type,
|
|
334
|
+
issueNumber,
|
|
335
|
+
state: issue.state,
|
|
336
|
+
labels: labelNames(issue),
|
|
337
|
+
docPaths,
|
|
338
|
+
forcedStep,
|
|
339
|
+
confirmed: yes || apply,
|
|
340
|
+
boardReady: !boardError,
|
|
341
|
+
force,
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
let decision = nextStep({ ...entrada, docs });
|
|
345
|
+
if (remoteCheck && decision.action !== 'none' && !decision.blocked) {
|
|
346
|
+
const docsRemotos = await probeRemote({ decision, docs, docPaths, token, owner, repo });
|
|
347
|
+
decision = nextStep({ ...entrada, docs: docsRemotos });
|
|
348
|
+
}
|
|
349
|
+
ultimaDecisao = decision;
|
|
350
|
+
|
|
351
|
+
// `--apply` é uma asserção sobre o estado esperado, não um "faça o que for":
|
|
352
|
+
// se o passo pendente virou outro, errar é melhor que sobrescrever um documento.
|
|
353
|
+
if (apply && decision.action !== 'decompose-apply' && !yes) {
|
|
354
|
+
throw new Error(
|
|
355
|
+
`--apply autoriza especificamente o \`decompose-apply\`, mas o passo pendente é ` +
|
|
356
|
+
`\`${decision.action}\`. Use --yes se era isso mesmo que você queria.`
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (json) console.log(JSON.stringify({ issue: Number(issueNumber), type, ...decision }, null, 2));
|
|
361
|
+
else reportDecision(decision, { issueNumber, type, title: issue.title, warnings: parityWarnings(issue, type) });
|
|
362
|
+
|
|
363
|
+
if (decision.blocked) { process.exitCode = 2; break; }
|
|
364
|
+
if (decision.action === 'none') break;
|
|
365
|
+
if (dryRun) {
|
|
366
|
+
console.log(chalk.dim('\nDry-run: nada foi executado.'));
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
console.log(chalk.bold(`\n▶ ${decision.command}\n`));
|
|
371
|
+
const resultado = await dispatch(decision.action, { issueNumber });
|
|
372
|
+
process.exitCode = exitCodeAntes;
|
|
373
|
+
|
|
374
|
+
// Reprova do validate é desfecho esperado, não exceção — mas encadear em
|
|
375
|
+
// cima dela repetiria o mesmo passo até esgotar `--max-steps`.
|
|
376
|
+
if (resultado?.ok === false) {
|
|
377
|
+
console.warn(chalk.yellow('\n⚠️ O passo terminou reprovado — corrija os problemas acima e repita.'));
|
|
378
|
+
process.exitCode = 2;
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const pendentes = unpushedCommits(root);
|
|
383
|
+
if (pendentes > 0) {
|
|
384
|
+
console.warn(chalk.yellow(
|
|
385
|
+
`\n⚠️ ${pendentes} commit(s) ainda não publicado(s). O próximo passo leria um documento ` +
|
|
386
|
+
'que só existe no seu clone — publique antes de continuar.'
|
|
387
|
+
));
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return ultimaDecisao;
|
|
393
|
+
} finally {
|
|
394
|
+
releaseLock(lock);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
@@ -5,12 +5,15 @@ import { getIssue, removeLabel, addLabel, commentOnIssue } from '../api/github-r
|
|
|
5
5
|
import { slugify } from '../lib/slugify.mjs';
|
|
6
6
|
import {
|
|
7
7
|
CONFIG_FILE, LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN, LABEL_BUG, LABEL_BUG_APPROVED,
|
|
8
|
+
LABEL_READY, LABEL_PLAN_APPROVED,
|
|
8
9
|
REQUIRED_PLAN_SECTIONS, REQUIRED_SPEC_SECTIONS, REQUIRED_BUG_SECTIONS, labelNames,
|
|
9
10
|
} from '../config.mjs';
|
|
10
11
|
import { findIncompleteDocSigns } from '../lib/doc-completeness.mjs';
|
|
11
12
|
import { bugDocPaths, describeMissingSections } from '../lib/bug-doc.mjs';
|
|
12
13
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
13
14
|
import { loadConfig } from '../lib/project-root.mjs';
|
|
15
|
+
import { executionMode } from '../lib/flow-run.mjs';
|
|
16
|
+
import { docBlobUrl } from '../lib/repo-links.mjs';
|
|
14
17
|
|
|
15
18
|
/**
|
|
16
19
|
* Validação do bug.md (RFC-004 §5).
|
|
@@ -55,7 +58,7 @@ async function validateBug({ token, owner, repo, issue, issueNumber, root }) {
|
|
|
55
58
|
}
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
await removeLabel(token, owner, repo, n,
|
|
61
|
+
await removeLabel(token, owner, repo, n, LABEL_READY);
|
|
59
62
|
|
|
60
63
|
if (errors.length > 0) {
|
|
61
64
|
await commentOnIssue(
|
|
@@ -65,18 +68,19 @@ async function validateBug({ token, owner, repo, issue, issueNumber, root }) {
|
|
|
65
68
|
`\n\nCorrija os problemas e adicione novamente a label \`spec-wave:ready\`.`
|
|
66
69
|
);
|
|
67
70
|
console.error('Validação falhou:', errors.join(', '));
|
|
68
|
-
|
|
71
|
+
return { ok: false, errors };
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
await addLabel(token, owner, repo, n, LABEL_BUG_APPROVED);
|
|
72
75
|
await commentOnIssue(
|
|
73
76
|
token, owner, repo, n,
|
|
74
77
|
'✅ **bug.md validado.**\n\n' +
|
|
75
|
-
`📄 [\`${fileRel}\`](
|
|
78
|
+
`📄 [\`${fileRel}\`](${docBlobUrl({ owner, repo, pathRel: fileRel, mode: executionMode(), root })})\n\n` +
|
|
76
79
|
'As seis seções obrigatórias estão presentes — reprodução, causa raiz e teste de ' +
|
|
77
80
|
'regressão inclusive. O bug pode ser aceito na triagem.'
|
|
78
81
|
);
|
|
79
82
|
console.log('bug.md validado.');
|
|
83
|
+
return { ok: true, errors: [] };
|
|
80
84
|
}
|
|
81
85
|
|
|
82
86
|
/**
|
|
@@ -95,6 +99,17 @@ export function renderMissingSection(doc, { section, found }) {
|
|
|
95
99
|
: base;
|
|
96
100
|
}
|
|
97
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Valida os documentos de uma issue.
|
|
104
|
+
*
|
|
105
|
+
* Devolve o veredito em vez de chamar `process.exit(1)` na reprova: quem
|
|
106
|
+
* encerra o processo é o wrapper do `bin` (mesmo exit code no Actions), e assim
|
|
107
|
+
* o `run` consegue tratar a reprova como o desfecho ESPERADO que ela é — sem
|
|
108
|
+
* morrer no meio da cadeia, sem pular o `finally` e sem deixar o lock para trás.
|
|
109
|
+
*
|
|
110
|
+
* @param {{issueNumber: string|number}} params
|
|
111
|
+
* @returns {Promise<{ok: boolean, errors: string[]}>}
|
|
112
|
+
*/
|
|
98
113
|
export async function validate({ issueNumber }) {
|
|
99
114
|
const token = await resolveToken();
|
|
100
115
|
const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
@@ -174,7 +189,7 @@ export async function validate({ issueNumber }) {
|
|
|
174
189
|
}
|
|
175
190
|
|
|
176
191
|
// Remove trigger label
|
|
177
|
-
await removeLabel(token, owner, repo, parseInt(issueNumber, 10),
|
|
192
|
+
await removeLabel(token, owner, repo, parseInt(issueNumber, 10), LABEL_READY);
|
|
178
193
|
|
|
179
194
|
if (errors.length > 0) {
|
|
180
195
|
await commentOnIssue(
|
|
@@ -199,11 +214,11 @@ export async function validate({ issueNumber }) {
|
|
|
199
214
|
//
|
|
200
215
|
// Regenerar é decisão humana, e o comentário acima diz como e o que custa.
|
|
201
216
|
console.error('Validação falhou:', errors.join(', '));
|
|
202
|
-
|
|
217
|
+
return { ok: false, errors };
|
|
203
218
|
}
|
|
204
219
|
|
|
205
220
|
// Validação passou: adiciona label plan-approved.
|
|
206
|
-
await addLabel(token, owner, repo, parseInt(issueNumber, 10),
|
|
221
|
+
await addLabel(token, owner, repo, parseInt(issueNumber, 10), LABEL_PLAN_APPROVED);
|
|
207
222
|
|
|
208
223
|
await commentOnIssue(
|
|
209
224
|
token, owner, repo, parseInt(issueNumber, 10),
|
|
@@ -216,4 +231,5 @@ export async function validate({ issueNumber }) {
|
|
|
216
231
|
);
|
|
217
232
|
|
|
218
233
|
console.log('Validação OK. Feature pronta para decomposição.');
|
|
234
|
+
return { ok: true, errors: [] };
|
|
219
235
|
}
|
package/src/config.mjs
CHANGED
|
@@ -11,17 +11,36 @@ export const PORTAL_URL = 'https://spec-wave.astratech.net.br';
|
|
|
11
11
|
// O provider e o modelo escolhidos no `init` são persistidos em .spec-wave.json
|
|
12
12
|
// (bloco `ai`) e lidos em runtime por src/lib/claude.mjs. Cada provider declara
|
|
13
13
|
// o secret do GitHub Actions de onde a chave é lida.
|
|
14
|
+
//
|
|
15
|
+
// `value` é o que o usuário escolhe; `backend` é QUEM executa (src/agent/).
|
|
16
|
+
// Os dois não coincidem: `anthropic` e `claude-oauth` rodam no mesmo backend e
|
|
17
|
+
// diferem só na credencial — chave de API por token vs. token da assinatura.
|
|
18
|
+
// Separar os campos evita que cada consumidor tenha que decorar essa relação.
|
|
14
19
|
export const AI_PROVIDERS = [
|
|
15
20
|
{
|
|
16
21
|
value: 'anthropic',
|
|
22
|
+
backend: 'anthropic',
|
|
17
23
|
label: 'Anthropic (API direta)',
|
|
18
24
|
hint: 'Usa o secret ANTHROPIC_API_KEY',
|
|
19
25
|
secret: 'ANTHROPIC_API_KEY',
|
|
20
26
|
defaultModel: 'claude-sonnet-4-6',
|
|
21
27
|
modelHint: 'ex.: claude-sonnet-4-6, claude-opus-4-1',
|
|
22
28
|
},
|
|
29
|
+
{
|
|
30
|
+
// Mesmo backend do `anthropic`, credencial diferente: o token da assinatura
|
|
31
|
+
// Claude Pro/Max (`claude setup-token`), com escopo só de inferência. O
|
|
32
|
+
// consumo sai do limite do plano, não de crédito por token.
|
|
33
|
+
value: 'claude-oauth',
|
|
34
|
+
backend: 'anthropic',
|
|
35
|
+
label: 'Claude (assinatura Pro/Max via OAuth)',
|
|
36
|
+
hint: 'Usa o secret CLAUDE_CODE_OAUTH_TOKEN — gere com `claude setup-token`',
|
|
37
|
+
secret: 'CLAUDE_CODE_OAUTH_TOKEN',
|
|
38
|
+
defaultModel: 'claude-sonnet-4-6',
|
|
39
|
+
modelHint: 'ex.: claude-sonnet-4-6, claude-opus-4-1',
|
|
40
|
+
},
|
|
23
41
|
{
|
|
24
42
|
value: 'openrouter',
|
|
43
|
+
backend: 'openrouter',
|
|
25
44
|
label: 'OpenRouter (multi-modelo)',
|
|
26
45
|
hint: 'Usa o secret OPENROUTER_API_KEY',
|
|
27
46
|
secret: 'OPENROUTER_API_KEY',
|
|
@@ -49,10 +68,12 @@ export const MODEL_LABEL_PREFIX = 'spec-wave:model:';
|
|
|
49
68
|
// `spec-wave:model:<apelido>` cubra "roda de novo num modelo mais forte" sem
|
|
50
69
|
// obrigar cada projeto a inventar a própria nomenclatura.
|
|
51
70
|
//
|
|
52
|
-
// O mapa é POR
|
|
53
|
-
// slug com "/" (anthropic/claude-opus-5) e a
|
|
54
|
-
// (claude-opus-5). Sugerir o formato errado geraria
|
|
55
|
-
// "apelido com formato incompatível" que o doctor emite
|
|
71
|
+
// O mapa é POR BACKEND, não por provider, porque o que ele distingue é o
|
|
72
|
+
// formato do id: a OpenRouter usa slug com "/" (anthropic/claude-opus-5) e a
|
|
73
|
+
// Anthropic usa o id nu (claude-opus-5). Sugerir o formato errado geraria
|
|
74
|
+
// exatamente o aviso de "apelido com formato incompatível" que o doctor emite
|
|
75
|
+
// na linha seguinte. `anthropic` e `claude-oauth` compartilham o mesmo mapa
|
|
76
|
+
// porque compartilham o backend.
|
|
56
77
|
export const RECOMMENDED_MODEL_ALIASES = {
|
|
57
78
|
openrouter: {
|
|
58
79
|
opus: 'anthropic/claude-opus-5',
|
|
@@ -78,7 +99,17 @@ export const RECOMMENDED_MODEL_ALIASES = {
|
|
|
78
99
|
* @returns {Record<string,string>}
|
|
79
100
|
*/
|
|
80
101
|
export function recommendedModelAliases(provider) {
|
|
81
|
-
return RECOMMENDED_MODEL_ALIASES[provider]
|
|
102
|
+
return RECOMMENDED_MODEL_ALIASES[providerBackend(provider)];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Backend que executa um provider (função PURA).
|
|
107
|
+
*
|
|
108
|
+
* @param {string} [value] valor de `ai.provider`
|
|
109
|
+
* @returns {'anthropic'|'openrouter'}
|
|
110
|
+
*/
|
|
111
|
+
export function providerBackend(value) {
|
|
112
|
+
return (getProvider(value) || getProvider(DEFAULT_PROVIDER)).backend;
|
|
82
113
|
}
|
|
83
114
|
|
|
84
115
|
// Quantas críticas seguidas podem reprovar a mesma issue antes de exigir revisão
|
|
@@ -376,6 +407,9 @@ export const LABEL_NEEDS_HUMAN = 'spec-wave:needs-human';
|
|
|
376
407
|
// pronta de uma que só foi movida à mão para ✅ Ready.
|
|
377
408
|
export const LABEL_PLAN_APPROVED = 'spec-wave:plan-approved';
|
|
378
409
|
// Gatilhos que REGERAM documento: presentes na issue, indicam etapa pendente.
|
|
410
|
+
// `spec-wave:ready` era o único gatilho sem constante — e por isso aparecia como
|
|
411
|
+
// string literal no validate, no YAML e em toda tabela que precisasse citá-lo.
|
|
412
|
+
export const LABEL_READY = 'spec-wave:ready';
|
|
379
413
|
export const LABEL_SPEC = 'spec-wave:spec';
|
|
380
414
|
export const LABEL_PLAN = 'spec-wave:plan';
|
|
381
415
|
// Achado GRAVE que o Tech Leader aceitou como risco conhecido. Fica na issue
|
|
@@ -391,7 +425,7 @@ export const TRIGGER_LABELS = [
|
|
|
391
425
|
{ name: LABEL_SPEC, color: 'BFD4F2', description: 'Gerar spec.md via GitHub Action' },
|
|
392
426
|
{ name: LABEL_PLAN, color: 'BFD4F2', description: 'Gerar plan.md via GitHub Action' },
|
|
393
427
|
{ name: LABEL_CRITIQUE, color: 'BFD4F2', description: 'Re-criticar o plan.md COMO ESTÁ, sem regerar' },
|
|
394
|
-
{ name:
|
|
428
|
+
{ name: LABEL_READY, color: '0E8A16', description: 'Validar spec+plan e mover para Ready' },
|
|
395
429
|
{ name: LABEL_PLAN_APPROVED, color: '0E8A16', description: 'Spec+plan validados com sucesso' },
|
|
396
430
|
{ name: LABEL_DECOMPOSE, color: 'BFD4F2', description: 'Gerar/re-criticar o rascunho da decomposição (decomposition.md)' },
|
|
397
431
|
{ name: LABEL_DECOMPOSE_APPLY, color: 'BFD4F2', description: 'Aplicar o decomposition.md revisado: criar Stories e Tasks' },
|
package/src/lib/claude.mjs
CHANGED
|
@@ -109,6 +109,9 @@ export function resolveAiConfig({ env = {}, fileAi = {}, action, labels = [], mo
|
|
|
109
109
|
|
|
110
110
|
return {
|
|
111
111
|
provider: meta.value,
|
|
112
|
+
// Quem executa. `anthropic` e `claude-oauth` caem no mesmo backend e
|
|
113
|
+
// diferem só na credencial — src/agent/ nunca vê essa distinção.
|
|
114
|
+
backend: meta.backend,
|
|
112
115
|
model: rawModel.trim(),
|
|
113
116
|
modelSource,
|
|
114
117
|
labelAlias,
|
|
@@ -359,7 +362,7 @@ export async function generateDocument(systemPrompt, userContent, opts = {}) {
|
|
|
359
362
|
// Extraída do generateDocument para o generateStructured usar a mesma contagem.
|
|
360
363
|
function recordUsageEntry(opts, ai, { inputTokens, outputTokens, cost }) {
|
|
361
364
|
if (!Array.isArray(opts.usage)) return;
|
|
362
|
-
const finalCost = cost === null && ai.
|
|
365
|
+
const finalCost = cost === null && ai.backend === 'anthropic'
|
|
363
366
|
? computeCost({ model: ai.model, inputTokens, outputTokens, pricing: ai.pricing })
|
|
364
367
|
: cost;
|
|
365
368
|
opts.usage.push({
|
|
@@ -507,7 +510,7 @@ async function generateViaEngine(
|
|
|
507
510
|
systemPrompt, userContent, ai, { temperature, maxTokens, maxTurns, schema, strict, action, tools },
|
|
508
511
|
) {
|
|
509
512
|
const result = await runAgent(userContent, {
|
|
510
|
-
provider: ai.
|
|
513
|
+
provider: ai.backend,
|
|
511
514
|
model: ai.model,
|
|
512
515
|
systemPromptAppend: systemPrompt,
|
|
513
516
|
// Sessão/usuário alimentam o agrupamento das traces no Langfuse. Sem
|
|
@@ -536,7 +539,14 @@ async function generateViaEngine(
|
|
|
536
539
|
if (result.structured === null || result.structured === undefined) {
|
|
537
540
|
const err = new Error(
|
|
538
541
|
`O modelo ${ai.model} não devolveu a saída estruturada "${schema.name}" ` +
|
|
539
|
-
`(subtype=${result.resultSubtype}).`
|
|
542
|
+
`(subtype=${result.resultSubtype}).` +
|
|
543
|
+
(result.resultSubtype === 'error_max_structured_output_retries'
|
|
544
|
+
// Subtype só do backend anthropic: o CLI repetiu o turno e a saída
|
|
545
|
+
// nunca bateu com o schema. Repetir tende a resolver; se insistir, o
|
|
546
|
+
// modelo é que não dá conta do contrato.
|
|
547
|
+
? ' O Claude Code esgotou as tentativas de casar a resposta com o schema —' +
|
|
548
|
+
' se repetir, aponte `ai.models.critique` para um modelo mais forte.'
|
|
549
|
+
: '')
|
|
540
550
|
);
|
|
541
551
|
err.transient = true; // repetir a mesma requisição costuma resolver
|
|
542
552
|
throw err;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Escrita do `.spec-wave.json` — um lugar só.
|
|
2
|
+
//
|
|
3
|
+
// Havia três escritores, cada um com a sua lógica: `init` monta o objeto do zero
|
|
4
|
+
// (e por isso descarta campos custom como `codeReview.linkMode` e `specKit`),
|
|
5
|
+
// `refresh` faz merge preservando o resto, e `update` tem a mesma lógica do
|
|
6
|
+
// refresh duplicada. Quem só precisa mexer em UM campo — o caso do `mode` — não
|
|
7
|
+
// tinha por onde.
|
|
8
|
+
//
|
|
9
|
+
// A serialização é parte do contrato, não detalhe: `update` compara o config
|
|
10
|
+
// byte a byte com o remoto para decidir se ele entra no PR (`lib/pr-branch.mjs`),
|
|
11
|
+
// então um `\n` a mais no fim faria o arquivo entrar em toda execução.
|
|
12
|
+
|
|
13
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
14
|
+
|
|
15
|
+
import { CONFIG_FILE } from '../config.mjs';
|
|
16
|
+
import { findConfigPath } from './project-root.mjs';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Serialização canônica do config (função PURA).
|
|
20
|
+
*
|
|
21
|
+
* @param {object} config
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
export function serializeConfig(config) {
|
|
25
|
+
return `${JSON.stringify(config, null, 2)}\n`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Aplica uma mutação ao `.spec-wave.json` preservando todo o resto.
|
|
30
|
+
*
|
|
31
|
+
* O mutator recebe uma CÓPIA e pode alterá-la no lugar ou devolver outro objeto.
|
|
32
|
+
* Gravação idempotente: conteúdo idêntico não reescreve o arquivo (não suja o
|
|
33
|
+
* mtime nem o `git status` de quem só consultou).
|
|
34
|
+
*
|
|
35
|
+
* @param {(config: object) => object|void} mutator
|
|
36
|
+
* @param {{cwd?: string}} [options]
|
|
37
|
+
* @returns {{configPath: string, config: object, changed: boolean}}
|
|
38
|
+
*/
|
|
39
|
+
export function updateConfig(mutator, { cwd = process.cwd() } = {}) {
|
|
40
|
+
const configPath = findConfigPath(cwd);
|
|
41
|
+
if (!configPath) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`${CONFIG_FILE} não encontrado — rode \`npx @spec-wave/cli@latest init\` neste repositório.`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const raw = readFileSync(configPath, 'utf-8');
|
|
48
|
+
let current;
|
|
49
|
+
try {
|
|
50
|
+
current = JSON.parse(raw);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
throw new Error(`${CONFIG_FILE} está corrompido (${err.message}) — corrija-o à mão antes.`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const draft = structuredClone(current);
|
|
56
|
+
const next = mutator(draft) ?? draft;
|
|
57
|
+
const content = serializeConfig(next);
|
|
58
|
+
if (content === raw) return { configPath, config: next, changed: false };
|
|
59
|
+
|
|
60
|
+
writeFileSync(configPath, content);
|
|
61
|
+
return { configPath, config: next, changed: true };
|
|
62
|
+
}
|