@spec-wave/cli 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/bin/spec-wave.mjs +44 -2
- package/package.json +8 -2
- package/src/agent/anthropic-agent.mjs +337 -0
- package/src/agent/errors.mjs +33 -0
- package/src/agent/index.mjs +108 -0
- package/src/agent/openrouter-agent.mjs +378 -0
- package/src/agent/run-types.mjs +59 -0
- package/src/agent/telemetry.mjs +54 -0
- package/src/agent/tools.mjs +452 -0
- package/src/agent/tracing.mjs +106 -0
- package/src/api/github-rest.mjs +206 -2
- package/src/commands/bug.mjs +8 -0
- package/src/commands/code-review.mjs +45 -4
- package/src/commands/decompose.mjs +11 -49
- package/src/commands/dev-agent.mjs +3 -3
- package/src/commands/doctor.mjs +77 -6
- package/src/commands/generate-bug.mjs +195 -0
- package/src/commands/generate-plan.mjs +6 -20
- package/src/commands/generate-spec.mjs +6 -22
- package/src/commands/implement.mjs +105 -2
- package/src/commands/init.mjs +3 -3
- package/src/commands/install-skill.mjs +72 -16
- package/src/commands/issue.mjs +9 -7
- package/src/commands/move.mjs +11 -1
- package/src/commands/qa.mjs +23 -2
- package/src/commands/refresh.mjs +145 -5
- package/src/commands/triage.mjs +174 -0
- package/src/commands/update.mjs +352 -62
- package/src/commands/validate.mjs +82 -10
- package/src/config.mjs +159 -1
- package/src/lib/bug-context.mjs +160 -0
- package/src/lib/bug-doc.mjs +51 -0
- package/src/lib/bug-triage.mjs +81 -0
- package/src/lib/claude.mjs +71 -254
- package/src/lib/critique.mjs +43 -30
- package/src/lib/implement-board.mjs +12 -1
- package/src/lib/plugin-skills.mjs +122 -0
- package/src/lib/pr-branch.mjs +267 -0
- package/src/lib/prompt-loader.mjs +257 -0
- package/src/lib/skill-file.mjs +35 -0
- package/src/plugin/.claude-plugin/plugin.json +20 -0
- package/src/plugin/README.md +73 -0
- package/src/plugin/skills/bug/SKILL.md +60 -0
- package/src/plugin/skills/bug/model-prompt.critique.md +48 -0
- package/src/plugin/skills/bug/model-prompt.md +74 -0
- package/src/plugin/skills/decompose/SKILL.md +111 -0
- package/src/plugin/skills/decompose/model-prompt.critique.md +46 -0
- package/src/plugin/skills/decompose/model-prompt.feature.md +69 -0
- package/src/plugin/skills/decompose/model-prompt.rfc.md +52 -0
- package/src/plugin/skills/doctor/SKILL.md +51 -0
- package/src/plugin/skills/fix-pr/SKILL.md +130 -0
- package/src/plugin/skills/implement/SKILL.md +102 -0
- package/src/plugin/skills/info/SKILL.md +40 -0
- package/src/plugin/skills/issue/SKILL.md +63 -0
- package/src/plugin/skills/move/SKILL.md +52 -0
- package/src/plugin/skills/order/SKILL.md +36 -0
- package/src/plugin/skills/plan/SKILL.md +53 -0
- package/src/plugin/skills/plan/model-prompt.critique.md +44 -0
- package/src/plugin/skills/plan/model-prompt.md +59 -0
- package/src/plugin/skills/plan/reference/tech-context.md +56 -0
- package/src/plugin/skills/ready/SKILL.md +44 -0
- package/src/plugin/skills/rfc/SKILL.md +47 -0
- package/src/plugin/skills/setup/SKILL.md +67 -0
- package/src/plugin/skills/spec/SKILL.md +37 -0
- package/src/plugin/skills/spec/model-prompt.md +61 -0
- package/src/plugin/skills/story/SKILL.md +49 -0
- package/src/plugin/skills/task/SKILL.md +41 -0
- package/src/plugin/skills/triage/SKILL.md +52 -0
- package/src/plugin/skills/uninstall/SKILL.md +43 -0
- package/src/plugin/skills/update/SKILL.md +51 -0
- package/src/plugin/skills/workflow/SKILL.md +154 -0
- package/src/templates/skill/SKILL.md +69 -7
- package/src/templates/workflows/generate-bug.yml +36 -0
- package/src/templates/workflows/validate.yml +2 -1
- package/src/ui/wizard.mjs +5 -2
package/src/commands/update.mjs
CHANGED
|
@@ -8,15 +8,21 @@ import { CONFIG_FILE, WORKFLOW_FILES, ISSUE_TEMPLATE_FILES, ALL_LABELS } from '.
|
|
|
8
8
|
import { getProjectSnapshot } from '../api/github-graphql.mjs';
|
|
9
9
|
import {
|
|
10
10
|
getFileContent, upsertFile, listLabels, createLabel, updateLabel, deleteLabel,
|
|
11
|
+
getRepoDefaultBranch, compareBranches, commitFilesToBranch, ensurePullRequest,
|
|
11
12
|
} from '../api/github-rest.mjs';
|
|
13
|
+
import {
|
|
14
|
+
resolveBranchName, composePrTitle, composePrBody, buildCommitMessage,
|
|
15
|
+
decideConfigInPr, explainGitWriteError,
|
|
16
|
+
} from '../lib/pr-branch.mjs';
|
|
12
17
|
// MESMO readTemplate do init: resolve {{CLI_VERSION}} antes da comparação byte a
|
|
13
18
|
// byte com o remoto. Se só o init resolvesse, todo update veria os workflows
|
|
14
19
|
// como desatualizados para sempre.
|
|
15
20
|
import { readTemplate } from '../lib/templates.mjs';
|
|
16
21
|
import {
|
|
17
22
|
TARGETS, SKILL_SOURCE, CLI_VERSION, parseSkill, renderContent,
|
|
18
|
-
mergeAgentsFile, resolveDest, isDetected, skillCopyReason,
|
|
23
|
+
mergeAgentsFile, resolveDest, isDetected, skillCopyReason, versionBanner,
|
|
19
24
|
} from './install-skill.mjs';
|
|
25
|
+
import { planPluginSkillFiles } from '../lib/plugin-skills.mjs';
|
|
20
26
|
import { findConfigPath } from '../lib/project-root.mjs';
|
|
21
27
|
|
|
22
28
|
// Arquivos do repo gerenciados pela CLI (comparados com o template empacotado).
|
|
@@ -34,15 +40,27 @@ function detectSkill(parsed, baseDir, isGlobal) {
|
|
|
34
40
|
const dest = resolveDest(target, baseDir, isGlobal);
|
|
35
41
|
if (!dest) continue;
|
|
36
42
|
const reason = skillCopyReason(dest, parsed);
|
|
37
|
-
if (reason)
|
|
38
|
-
|
|
43
|
+
if (!reason) continue;
|
|
44
|
+
// `skills-dir` (Codex) não tem um `desired` único: são N arquivos, um por
|
|
45
|
+
// skill do plugin. O plano é montado na hora de aplicar.
|
|
46
|
+
if (dest.format === 'skills-dir') {
|
|
47
|
+
jobs.push({ target, dest, reason });
|
|
48
|
+
continue;
|
|
39
49
|
}
|
|
50
|
+
jobs.push({ target, dest, desired: renderContent(dest.format, parsed, CLI_VERSION), reason });
|
|
40
51
|
}
|
|
41
52
|
return jobs;
|
|
42
53
|
}
|
|
43
54
|
|
|
44
55
|
// Aplica a atualização de uma skill (grava o arquivo / faz merge no AGENTS.md).
|
|
45
56
|
function applySkill(job) {
|
|
57
|
+
if (job.dest.format === 'skills-dir') {
|
|
58
|
+
for (const file of planPluginSkillFiles(job.dest.path, CLI_VERSION, versionBanner)) {
|
|
59
|
+
mkdirSync(path.dirname(file.path), { recursive: true });
|
|
60
|
+
writeFileSync(file.path, file.content, 'utf-8');
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
46
64
|
const content = job.dest.format === 'agents'
|
|
47
65
|
? mergeAgentsFile(job.dest.path, job.desired)
|
|
48
66
|
: job.desired;
|
|
@@ -85,12 +103,102 @@ export function diffLabels(existing) {
|
|
|
85
103
|
return { missing, changed, orphan };
|
|
86
104
|
}
|
|
87
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Regenera o conteúdo do .spec-wave.json a partir do Project remoto SEM GRAVAR.
|
|
108
|
+
*
|
|
109
|
+
* A geração foi separada da gravação porque no modo --branch esse conteúdo pode
|
|
110
|
+
* precisar entrar na árvore do commit — e a árvore é montada antes de QUALQUER
|
|
111
|
+
* escrita, para que uma falha no caminho não deixe nada aplicado, nem local nem
|
|
112
|
+
* remoto.
|
|
113
|
+
*
|
|
114
|
+
* @returns {Promise<{ content: string|null, error: string|null }>}
|
|
115
|
+
*/
|
|
116
|
+
async function buildConfigContent(token, config) {
|
|
117
|
+
const snapshot = await getProjectSnapshot(token, config.project.id);
|
|
118
|
+
if (!snapshot) return { content: null, error: 'Project não encontrado' };
|
|
119
|
+
const { etapaFieldId: _e, stageOptions: _s, ...projectRest } = config.project;
|
|
120
|
+
const updated = {
|
|
121
|
+
...config,
|
|
122
|
+
version: CLI_VERSION,
|
|
123
|
+
project: {
|
|
124
|
+
...projectRest,
|
|
125
|
+
title: snapshot.title,
|
|
126
|
+
url: snapshot.url,
|
|
127
|
+
id: snapshot.id,
|
|
128
|
+
number: snapshot.number,
|
|
129
|
+
fields: snapshot.fields,
|
|
130
|
+
},
|
|
131
|
+
refreshedAt: new Date().toISOString(),
|
|
132
|
+
};
|
|
133
|
+
// MESMA serialização do init (2 espaços + \n final): este texto é comparado
|
|
134
|
+
// byte a byte com o remoto em decideConfigInPr, e um \n de diferença faria o
|
|
135
|
+
// config entrar no PR em toda execução.
|
|
136
|
+
return { content: `${JSON.stringify(updated, null, 2)}\n`, error: null };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Aplica o diff de labels direto na base.
|
|
141
|
+
*
|
|
142
|
+
* Label NUNCA entra num PR: é metadado do repositório, não arquivo versionado —
|
|
143
|
+
* não existe forma de propor a mudança para revisão.
|
|
144
|
+
*
|
|
145
|
+
* @returns {Promise<{created: string[], updated: string[], removed: string[]}>}
|
|
146
|
+
* só o que REALMENTE passou — é o que o corpo do PR vai afirmar a quem
|
|
147
|
+
* revisa, e ele não pode prometer o que falhou.
|
|
148
|
+
*/
|
|
149
|
+
async function applyLabels(token, owner, repo, labelDiff) {
|
|
150
|
+
const done = { created: [], updated: [], removed: [] };
|
|
151
|
+
for (const label of labelDiff.missing) {
|
|
152
|
+
try {
|
|
153
|
+
await createLabel(token, owner, repo, label);
|
|
154
|
+
done.created.push(label.name);
|
|
155
|
+
p.log.success(`Label criada: ${label.name}`);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
p.log.error(`Falha ao criar label ${label.name}: ${err.message}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
for (const label of labelDiff.changed) {
|
|
161
|
+
try {
|
|
162
|
+
await updateLabel(token, owner, repo, label);
|
|
163
|
+
done.updated.push(label.name);
|
|
164
|
+
p.log.success(`Label atualizada: ${label.name}`);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
p.log.error(`Falha ao atualizar label ${label.name}: ${err.message}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
for (const label of labelDiff.orphan) {
|
|
170
|
+
try {
|
|
171
|
+
await deleteLabel(token, owner, repo, label.name);
|
|
172
|
+
done.removed.push(label.name);
|
|
173
|
+
p.log.success(`Label descontinuada removida: ${label.name}`);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
p.log.error(`Falha ao remover label ${label.name}: ${err.message}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return done;
|
|
179
|
+
}
|
|
180
|
+
|
|
88
181
|
export async function update(options = {}) {
|
|
89
182
|
p.intro(chalk.bold(`spec-wave update (CLI v${CLI_VERSION})`));
|
|
90
183
|
|
|
91
184
|
const isGlobal = !!options.global;
|
|
92
185
|
const baseDir = isGlobal ? homedir() : process.cwd();
|
|
93
186
|
|
|
187
|
+
// --branch: valida o nome ANTES de qualquer rede. Descobrir um espaço no nome
|
|
188
|
+
// só no createRef, depois de meia dúzia de requisições, é o pior desperdício
|
|
189
|
+
// possível — e a mensagem crua da API não aponta a causa.
|
|
190
|
+
const prRequested = options.branch !== undefined && options.branch !== false;
|
|
191
|
+
let branch = null;
|
|
192
|
+
if (prRequested) {
|
|
193
|
+
const r = resolveBranchName(options.branch, { version: CLI_VERSION });
|
|
194
|
+
if (r.error) {
|
|
195
|
+
p.log.error(r.error);
|
|
196
|
+
p.cancel('Update cancelado.');
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
branch = r.branch;
|
|
200
|
+
}
|
|
201
|
+
|
|
94
202
|
// ---------- Detecção ----------
|
|
95
203
|
// 1) Skill (por agente detectado).
|
|
96
204
|
const parsed = parseSkill(readFileSync(SKILL_SOURCE, 'utf-8'));
|
|
@@ -99,9 +207,11 @@ export async function update(options = {}) {
|
|
|
99
207
|
// 2) Config + repo dependem do .spec-wave.json local do repo atual.
|
|
100
208
|
const configPath = findConfigPath();
|
|
101
209
|
let config = null;
|
|
210
|
+
let rawConfig = null; // texto cru: comparado com o remoto em decideConfigInPr
|
|
102
211
|
if (configPath) {
|
|
103
212
|
try {
|
|
104
|
-
|
|
213
|
+
rawConfig = readFileSync(configPath, 'utf-8');
|
|
214
|
+
config = JSON.parse(rawConfig);
|
|
105
215
|
} catch (err) {
|
|
106
216
|
p.log.warn(`${CONFIG_FILE} corrompido (${err.message}); pulando config/repo.`);
|
|
107
217
|
}
|
|
@@ -136,6 +246,8 @@ export async function update(options = {}) {
|
|
|
136
246
|
}
|
|
137
247
|
|
|
138
248
|
// 2b) Arquivos do repo e labels divergentes (exige token + rede).
|
|
249
|
+
const owner = config?.owner;
|
|
250
|
+
const repo = config?.repo;
|
|
139
251
|
let repoFiles = [];
|
|
140
252
|
let labelDiff = { missing: [], changed: [], orphan: [] };
|
|
141
253
|
let repoChecked = false;
|
|
@@ -147,7 +259,6 @@ export async function update(options = {}) {
|
|
|
147
259
|
s.stop('');
|
|
148
260
|
p.log.warn(`Sem token do GitHub (${tokenError?.message ?? 'indisponível'}); pulando verificação do repo.`);
|
|
149
261
|
} else {
|
|
150
|
-
const { owner, repo } = config;
|
|
151
262
|
try {
|
|
152
263
|
for (const f of REPO_FILES) {
|
|
153
264
|
const remote = await getFileContent(tk, owner, repo, f.repoPath);
|
|
@@ -165,9 +276,67 @@ export async function update(options = {}) {
|
|
|
165
276
|
}
|
|
166
277
|
}
|
|
167
278
|
|
|
279
|
+
// 2c) Modo PR: base remota, estado do config na base e situação da branch.
|
|
280
|
+
// TUDO leitura — precisa acontecer aqui porque o resumo e o --dry-run já
|
|
281
|
+
// mostram a branch, a base e se o config vai no PR.
|
|
282
|
+
let prMode = prRequested && doRepo && repoChecked;
|
|
283
|
+
let base = null;
|
|
284
|
+
let remoteConfigRaw = null;
|
|
285
|
+
let branchExists = false;
|
|
286
|
+
if (prMode) {
|
|
287
|
+
const tk = await getToken();
|
|
288
|
+
try {
|
|
289
|
+
base = await getRepoDefaultBranch(tk, owner, repo);
|
|
290
|
+
if (branch === base) {
|
|
291
|
+
p.log.error(
|
|
292
|
+
`--branch "${branch}" é a própria branch default do repositório; ` +
|
|
293
|
+
'um Pull Request precisa de uma branch diferente da base.'
|
|
294
|
+
);
|
|
295
|
+
p.cancel('Update cancelado.');
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
remoteConfigRaw = await getFileContent(tk, owner, repo, CONFIG_FILE, base);
|
|
299
|
+
const status = await compareBranches(tk, owner, repo, base, branch);
|
|
300
|
+
branchExists = status !== null;
|
|
301
|
+
if (status === 'behind' || status === 'identical') {
|
|
302
|
+
// Não fazemos force-push: só avisamos. Empilhar um commit numa branch já
|
|
303
|
+
// mergeada funciona, mas o diff do PR pode reexibir arquivos que já estão
|
|
304
|
+
// na base (caso do squash merge).
|
|
305
|
+
p.log.warn(
|
|
306
|
+
`A branch "${branch}" já existe e não tem nenhum commit além de "${base}" ` +
|
|
307
|
+
'(provavelmente de um PR já mergeado). O commit novo seria empilhado nela — ' +
|
|
308
|
+
'use `--branch <outro-nome>` para começar do zero.'
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
} catch (err) {
|
|
312
|
+
p.log.warn(
|
|
313
|
+
`Não foi possível preparar o modo PR: ${err.message} — ` +
|
|
314
|
+
'os arquivos seguiriam por commit direto.'
|
|
315
|
+
);
|
|
316
|
+
prMode = false;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Decisão preliminar sobre o config. O conteúdo regenerado ainda não existe;
|
|
321
|
+
// `willRegenerate` cobre isso, porque o regenerado SEMPRE difere do remoto
|
|
322
|
+
// (refreshedAt muda a cada execução). Na aplicação a decisão é recalculada com
|
|
323
|
+
// o conteúdo real.
|
|
324
|
+
const configDecision = prMode && doConfig
|
|
325
|
+
? decideConfigInPr({
|
|
326
|
+
remote: remoteConfigRaw,
|
|
327
|
+
desired: rawConfig,
|
|
328
|
+
willRegenerate: !!configStale?.canApply,
|
|
329
|
+
force: options.configInPr,
|
|
330
|
+
})
|
|
331
|
+
: { included: false, reason: '' };
|
|
332
|
+
|
|
168
333
|
// ---------- Resumo ----------
|
|
169
334
|
const labelTotal = labelDiff.missing.length + labelDiff.changed.length + labelDiff.orphan.length;
|
|
170
|
-
|
|
335
|
+
// Config em dia pela versão, mas divergente do que está na base: antes isso dava
|
|
336
|
+
// "tudo atualizado" e o repositório ficava com um config velho indefinidamente.
|
|
337
|
+
const configOnlyPr = configDecision.included && !configStale;
|
|
338
|
+
const total = skillJobs.length + (configStale ? 1 : 0) + repoFiles.length + labelTotal
|
|
339
|
+
+ (configOnlyPr ? 1 : 0);
|
|
171
340
|
|
|
172
341
|
if (total === 0) {
|
|
173
342
|
p.log.success('Tudo já está atualizado para a versão atual da CLI.');
|
|
@@ -186,11 +355,22 @@ export async function update(options = {}) {
|
|
|
186
355
|
(configStale.canApply ? '' : chalk.dim(' (sem project.id — rode `init` sem --skip-project)')));
|
|
187
356
|
}
|
|
188
357
|
if (repoFiles.length) {
|
|
189
|
-
lines.push(
|
|
358
|
+
lines.push(prMode
|
|
359
|
+
? chalk.bold(`Arquivos do repo → Pull Request (${branch} → ${base}):`)
|
|
360
|
+
: chalk.bold('Arquivos do repo:'));
|
|
190
361
|
for (const f of repoFiles) lines.push(` ${chalk.yellow('↻')} ${f.repoPath} (${f.reason})`);
|
|
191
362
|
}
|
|
363
|
+
if (prMode && configDecision.included) {
|
|
364
|
+
if (!repoFiles.length) lines.push(chalk.bold(`Pull Request (${branch} → ${base}):`));
|
|
365
|
+
lines.push(` ${chalk.yellow('↻')} ${CONFIG_FILE} (${configDecision.reason})`);
|
|
366
|
+
}
|
|
367
|
+
if (prMode && doConfig && !configDecision.included && configDecision.reason) {
|
|
368
|
+
lines.push(chalk.dim(` · ${CONFIG_FILE} fora do PR — ${configDecision.reason}`));
|
|
369
|
+
}
|
|
192
370
|
if (labelTotal) {
|
|
193
|
-
lines.push(
|
|
371
|
+
lines.push(prMode
|
|
372
|
+
? chalk.bold(`Labels (direto em ${base} — metadado do repo, não versionável):`)
|
|
373
|
+
: chalk.bold('Labels:'));
|
|
194
374
|
if (labelDiff.missing.length) lines.push(` ${chalk.yellow('+')} criar: ${labelDiff.missing.map(l => l.name).join(', ')}`);
|
|
195
375
|
if (labelDiff.changed.length) lines.push(` ${chalk.yellow('↻')} atualizar: ${labelDiff.changed.map(l => l.name).join(', ')}`);
|
|
196
376
|
if (labelDiff.orphan.length) {
|
|
@@ -199,13 +379,48 @@ export async function update(options = {}) {
|
|
|
199
379
|
}
|
|
200
380
|
p.note(lines.join('\n'), `${total} item(ns) desatualizado(s)`);
|
|
201
381
|
|
|
382
|
+
// --branch sem efeito: melhor dizer POR QUE do que criar uma branch inútil.
|
|
383
|
+
const prHasPayload = prMode && (repoFiles.length > 0 || configDecision.included);
|
|
384
|
+
if (prRequested && options.skipRepo) {
|
|
385
|
+
p.log.warn(
|
|
386
|
+
'--branch ignorado junto com --skip-repo: o Pull Request existe justamente para ' +
|
|
387
|
+
'levar os arquivos do repo.'
|
|
388
|
+
);
|
|
389
|
+
} else if (prRequested && !prMode) {
|
|
390
|
+
p.log.warn(
|
|
391
|
+
'--branch ignorado: sem token, sem owner/repo no .spec-wave.json ou a comparação com ' +
|
|
392
|
+
'o repositório falhou — não há como abrir o Pull Request.'
|
|
393
|
+
);
|
|
394
|
+
} else if (prMode && !prHasPayload) {
|
|
395
|
+
p.log.warn(
|
|
396
|
+
'--branch sem efeito: nenhum arquivo do repositório para enviar (as mudanças são ' +
|
|
397
|
+
'locais e/ou de labels). Nenhuma branch será criada.'
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
202
401
|
if (options.dryRun) {
|
|
402
|
+
if (prHasPayload) {
|
|
403
|
+
const n = repoFiles.length + (configDecision.included ? 1 : 0);
|
|
404
|
+
p.note(
|
|
405
|
+
`Branch: ${branch}${branchExists ? ' (já existe — o commit seria empilhado nela)' : ' (seria criada)'}\n` +
|
|
406
|
+
`Base: ${base}\n` +
|
|
407
|
+
`Título: ${composePrTitle({ version: CLI_VERSION })}\n` +
|
|
408
|
+
`Arquivos: ${n} em 1 commit único\n\n` +
|
|
409
|
+
'Nada foi criado: nem branch, nem commit, nem Pull Request.',
|
|
410
|
+
'Dry-run: modo Pull Request'
|
|
411
|
+
);
|
|
412
|
+
}
|
|
203
413
|
p.outro('Dry-run: nada foi alterado.');
|
|
204
414
|
return;
|
|
205
415
|
}
|
|
206
416
|
|
|
207
417
|
if (!options.yes) {
|
|
208
|
-
const ok = await p.confirm({
|
|
418
|
+
const ok = await p.confirm({
|
|
419
|
+
message: prHasPayload
|
|
420
|
+
? `Aplicar as ${total} atualização(ões) e abrir um Pull Request em "${branch}"?`
|
|
421
|
+
: `Aplicar as ${total} atualização(ões)?`,
|
|
422
|
+
initialValue: true,
|
|
423
|
+
});
|
|
209
424
|
if (p.isCancel(ok) || !ok) {
|
|
210
425
|
p.cancel('Update cancelado.');
|
|
211
426
|
return;
|
|
@@ -213,6 +428,14 @@ export async function update(options = {}) {
|
|
|
213
428
|
}
|
|
214
429
|
|
|
215
430
|
// ---------- Aplicação ----------
|
|
431
|
+
// Ordem: skill (local) → config GERADO em memória → labels (sempre direto) →
|
|
432
|
+
// arquivos do repo (PR ou commits diretos) → gravação local do config.
|
|
433
|
+
//
|
|
434
|
+
// As labels vêm ANTES dos arquivos porque o corpo do PR precisa dizer quais
|
|
435
|
+
// labels JÁ mudaram na base — é a única coisa que o update faz e o diff do PR
|
|
436
|
+
// não mostra. (Isso muda a ordem dos logs também no modo direto; os dois blocos
|
|
437
|
+
// são independentes.)
|
|
438
|
+
|
|
216
439
|
// Skill
|
|
217
440
|
for (const job of skillJobs) {
|
|
218
441
|
try {
|
|
@@ -223,7 +446,8 @@ export async function update(options = {}) {
|
|
|
223
446
|
}
|
|
224
447
|
}
|
|
225
448
|
|
|
226
|
-
// Config (.spec-wave.json) —
|
|
449
|
+
// Config (.spec-wave.json) — regenera o conteúdo, ainda sem tocar o disco.
|
|
450
|
+
let configContent = null;
|
|
227
451
|
if (configStale) {
|
|
228
452
|
if (!configStale.canApply) {
|
|
229
453
|
p.log.warn(`${CONFIG_FILE}: sem project.id — pulei. Rode \`npx @spec-wave/cli@latest init\` (sem --skip-project).`);
|
|
@@ -233,83 +457,149 @@ export async function update(options = {}) {
|
|
|
233
457
|
p.log.warn(`${CONFIG_FILE}: sem token — pulei. (${tokenError?.message ?? ''})`);
|
|
234
458
|
} else {
|
|
235
459
|
const s = p.spinner();
|
|
236
|
-
s.start('
|
|
460
|
+
s.start('Consultando o Project para atualizar o .spec-wave.json...');
|
|
237
461
|
try {
|
|
238
|
-
const
|
|
239
|
-
if (
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
...config,
|
|
243
|
-
version: CLI_VERSION,
|
|
244
|
-
project: {
|
|
245
|
-
...projectRest,
|
|
246
|
-
title: snapshot.title,
|
|
247
|
-
url: snapshot.url,
|
|
248
|
-
id: snapshot.id,
|
|
249
|
-
number: snapshot.number,
|
|
250
|
-
fields: snapshot.fields,
|
|
251
|
-
},
|
|
252
|
-
refreshedAt: new Date().toISOString(),
|
|
253
|
-
};
|
|
254
|
-
writeFileSync(configPath, JSON.stringify(updated, null, 2) + '\n');
|
|
255
|
-
s.stop(`${CONFIG_FILE} atualizado (v${CLI_VERSION}).`);
|
|
462
|
+
const { content, error } = await buildConfigContent(tk, config);
|
|
463
|
+
if (error) throw new Error(error);
|
|
464
|
+
configContent = content;
|
|
465
|
+
s.stop(`${CONFIG_FILE} regenerado (v${CLI_VERSION}).`);
|
|
256
466
|
} catch (err) {
|
|
257
467
|
s.stop('');
|
|
258
|
-
p.log.error(`Falha ao
|
|
468
|
+
p.log.error(`Falha ao regerar ${CONFIG_FILE}: ${err.message}`);
|
|
259
469
|
}
|
|
260
470
|
}
|
|
261
471
|
}
|
|
262
472
|
}
|
|
263
473
|
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
474
|
+
// Recalcula a decisão com o conteúdo REAL (a preliminar usava willRegenerate).
|
|
475
|
+
const finalConfigDecision = prMode && doConfig
|
|
476
|
+
? decideConfigInPr({
|
|
477
|
+
remote: remoteConfigRaw,
|
|
478
|
+
desired: configContent ?? rawConfig,
|
|
479
|
+
force: options.configInPr,
|
|
480
|
+
})
|
|
481
|
+
: { included: false, reason: '' };
|
|
482
|
+
|
|
483
|
+
// Gravação local do config, feita UMA vez só. No modo PR com o config a bordo
|
|
484
|
+
// ela é adiada para depois do commit remoto: assim uma falha no envio não deixa
|
|
485
|
+
// o arquivo local adiantado em relação ao repositório.
|
|
486
|
+
let configFlushed = false;
|
|
487
|
+
const flushConfig = () => {
|
|
488
|
+
if (configFlushed || !configContent) return;
|
|
489
|
+
try {
|
|
490
|
+
writeFileSync(configPath, configContent);
|
|
491
|
+
configFlushed = true;
|
|
492
|
+
p.log.success(`${CONFIG_FILE} atualizado localmente (v${CLI_VERSION}).`);
|
|
493
|
+
} catch (err) {
|
|
494
|
+
p.log.error(`Falha ao gravar ${CONFIG_FILE}: ${err.message}`);
|
|
275
495
|
}
|
|
276
|
-
}
|
|
496
|
+
};
|
|
497
|
+
if (!finalConfigDecision.included) flushConfig();
|
|
277
498
|
|
|
278
|
-
// Labels
|
|
499
|
+
// Labels — sempre direto na base (metadado do repo, não versionável).
|
|
500
|
+
let labelResult = { created: [], updated: [], removed: [] };
|
|
279
501
|
if (labelTotal) {
|
|
502
|
+
labelResult = await applyLabels(await getToken(), owner, repo, labelDiff);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Arquivos do repo
|
|
506
|
+
let prUrl = null;
|
|
507
|
+
if (prMode) {
|
|
280
508
|
const tk = await getToken();
|
|
281
|
-
const
|
|
282
|
-
|
|
509
|
+
const treeFiles = [
|
|
510
|
+
...repoFiles.map(f => ({ path: f.repoPath, reason: f.reason, content: f.local })),
|
|
511
|
+
...(finalConfigDecision.included
|
|
512
|
+
? [{
|
|
513
|
+
path: CONFIG_FILE,
|
|
514
|
+
reason: finalConfigDecision.reason,
|
|
515
|
+
content: configContent ?? rawConfig,
|
|
516
|
+
}]
|
|
517
|
+
: []),
|
|
518
|
+
];
|
|
519
|
+
if (!treeFiles.length) {
|
|
520
|
+
p.log.warn('Nada para enviar em um Pull Request: nenhum arquivo do repositório mudou.');
|
|
521
|
+
} else {
|
|
522
|
+
const s = p.spinner();
|
|
523
|
+
s.start(`Enviando ${treeFiles.length} arquivo(s) em um único commit para "${branch}"...`);
|
|
524
|
+
let commitOk = false;
|
|
283
525
|
try {
|
|
284
|
-
await
|
|
285
|
-
|
|
526
|
+
const res = await commitFilesToBranch(tk, owner, repo, {
|
|
527
|
+
branch,
|
|
528
|
+
base,
|
|
529
|
+
files: treeFiles.map(f => ({ path: f.path, content: f.content })),
|
|
530
|
+
message: buildCommitMessage({ version: CLI_VERSION, files: treeFiles }),
|
|
531
|
+
});
|
|
532
|
+
commitOk = true;
|
|
533
|
+
s.stop(res.unchanged
|
|
534
|
+
? `"${branch}" já contém estas alterações — nenhum commit novo.`
|
|
535
|
+
: `Commit ${res.commitSha.slice(0, 7)} enviado para "${branch}" (${treeFiles.length} arquivo(s)).`);
|
|
286
536
|
} catch (err) {
|
|
287
|
-
|
|
537
|
+
s.stop('');
|
|
538
|
+
p.log.error(`Falha ao enviar os arquivos para "${branch}": ${explainGitWriteError(err)}`);
|
|
288
539
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
540
|
+
|
|
541
|
+
// O PR fica num try SEPARADO: a falha aqui não invalida o commit, e a
|
|
542
|
+
// mensagem precisa dizer que a branch existe. Um token com "Contents:
|
|
543
|
+
// write" pode não ter "Pull requests: write" — permissão NOVA neste modo.
|
|
544
|
+
if (commitOk) {
|
|
545
|
+
try {
|
|
546
|
+
const pr = await ensurePullRequest(tk, owner, repo, {
|
|
547
|
+
branch,
|
|
548
|
+
base,
|
|
549
|
+
title: composePrTitle({ version: CLI_VERSION }),
|
|
550
|
+
body: composePrBody({
|
|
551
|
+
version: CLI_VERSION,
|
|
552
|
+
base,
|
|
553
|
+
branch,
|
|
554
|
+
files: treeFiles,
|
|
555
|
+
config: doConfig ? finalConfigDecision : null,
|
|
556
|
+
labels: labelResult,
|
|
557
|
+
skill: skillJobs.map(j => j.target.name),
|
|
558
|
+
}),
|
|
559
|
+
});
|
|
560
|
+
if (!pr) {
|
|
561
|
+
p.log.warn(
|
|
562
|
+
`A branch "${branch}" está em dia com "${base}" e não há PR aberto para ela — ` +
|
|
563
|
+
'nada a revisar.'
|
|
564
|
+
);
|
|
565
|
+
} else {
|
|
566
|
+
prUrl = pr.url;
|
|
567
|
+
p.log.success(pr.created
|
|
568
|
+
? `Pull Request aberto: #${pr.number} — ${pr.url}`
|
|
569
|
+
: `Pull Request já aberto para "${branch}": #${pr.number} — ${pr.url}`);
|
|
570
|
+
}
|
|
571
|
+
flushConfig();
|
|
572
|
+
} catch (err) {
|
|
573
|
+
p.log.error(`Commit enviado, mas não foi possível abrir o PR: ${explainGitWriteError(err)}`);
|
|
574
|
+
p.log.info(
|
|
575
|
+
'Abra manualmente: ' +
|
|
576
|
+
`https://github.com/${owner}/${repo}/compare/${base}...${encodeURIComponent(branch)}?expand=1`
|
|
577
|
+
);
|
|
578
|
+
}
|
|
296
579
|
}
|
|
297
580
|
}
|
|
298
|
-
|
|
581
|
+
} else if (repoFiles.length) {
|
|
582
|
+
const tk = await getToken();
|
|
583
|
+
for (const f of repoFiles) {
|
|
299
584
|
try {
|
|
300
|
-
await
|
|
301
|
-
p.log.success(`
|
|
585
|
+
await upsertFile(tk, owner, repo, f.repoPath, f.local, `chore: update ${path.basename(f.repoPath)} [spec-wave]`);
|
|
586
|
+
p.log.success(`Arquivo atualizado no repo: ${f.repoPath}`);
|
|
302
587
|
} catch (err) {
|
|
303
|
-
p.log.error(`Falha ao
|
|
588
|
+
p.log.error(`Falha ao atualizar ${f.repoPath}: ${err.message}`);
|
|
304
589
|
}
|
|
305
590
|
}
|
|
306
591
|
}
|
|
307
592
|
|
|
308
|
-
const
|
|
593
|
+
const configPending = configFlushed && !finalConfigDecision.included;
|
|
309
594
|
p.outro(
|
|
310
595
|
'Update concluído.' +
|
|
311
596
|
(skillJobs.length ? ' Recarregue o agente para pegar a skill nova.' : '') +
|
|
312
|
-
(
|
|
313
|
-
(
|
|
597
|
+
(prUrl ? ` Revise e faça o merge do Pull Request: ${prUrl}` : '') +
|
|
598
|
+
(!prMode && repoFiles.length ? ' Arquivos do repo foram commitados no remoto.' : '') +
|
|
599
|
+
(configPending ? ` Faça commit do ${CONFIG_FILE}.` : '') +
|
|
600
|
+
(finalConfigDecision.included
|
|
601
|
+
? ` O ${CONFIG_FILE} local ficou igual ao do PR — depois do merge, descarte a cópia ` +
|
|
602
|
+
`local com \`git checkout -- ${CONFIG_FILE}\`.`
|
|
603
|
+
: '')
|
|
314
604
|
);
|
|
315
605
|
}
|
|
@@ -4,12 +4,81 @@ import { resolveToken } from '../api/auth.mjs';
|
|
|
4
4
|
import { getIssue, removeLabel, addLabel, commentOnIssue } from '../api/github-rest.mjs';
|
|
5
5
|
import { slugify } from '../lib/slugify.mjs';
|
|
6
6
|
import {
|
|
7
|
-
CONFIG_FILE, LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN,
|
|
8
|
-
REQUIRED_PLAN_SECTIONS, REQUIRED_SPEC_SECTIONS, labelNames,
|
|
7
|
+
CONFIG_FILE, LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN, LABEL_BUG, LABEL_BUG_APPROVED,
|
|
8
|
+
REQUIRED_PLAN_SECTIONS, REQUIRED_SPEC_SECTIONS, REQUIRED_BUG_SECTIONS, labelNames,
|
|
9
9
|
} from '../config.mjs';
|
|
10
10
|
import { findIncompleteDocSigns } from '../lib/doc-completeness.mjs';
|
|
11
|
+
import { bugDocPaths, findMissingSections } from '../lib/bug-doc.mjs';
|
|
12
|
+
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
11
13
|
import { loadConfig } from '../lib/project-root.mjs';
|
|
12
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Validação do bug.md (RFC-004 §5).
|
|
17
|
+
*
|
|
18
|
+
* Mesma forma da validação de Feature — seções obrigatórias + sinais de
|
|
19
|
+
* documento truncado + portões humanos da crítica — com duas diferenças:
|
|
20
|
+
* um único arquivo, e a falha NÃO devolve o item para a etapa de spec (Bug não
|
|
21
|
+
* tem etapa de spec). Reaplicar `spec-wave:bug` é o caminho de retomada.
|
|
22
|
+
*/
|
|
23
|
+
async function validateBug({ token, owner, repo, issue, issueNumber, root }) {
|
|
24
|
+
const n = parseInt(issueNumber, 10);
|
|
25
|
+
const { fileRel, fileAbs } = bugDocPaths(issue.title, root);
|
|
26
|
+
const errors = [];
|
|
27
|
+
|
|
28
|
+
const names = labelNames(issue);
|
|
29
|
+
const critiqueFailed = names.includes(LABEL_CRITIQUE_FAILED);
|
|
30
|
+
const needsHuman = names.includes(LABEL_NEEDS_HUMAN);
|
|
31
|
+
if (critiqueFailed) {
|
|
32
|
+
errors.push(
|
|
33
|
+
'🔎 A crítica adversarial apontou contradições GRAVES no `bug.md` (veja o comentário na ' +
|
|
34
|
+
`issue). Corrija e remova a label \`${LABEL_CRITIQUE_FAILED}\`.`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
if (needsHuman) {
|
|
38
|
+
errors.push(
|
|
39
|
+
`🛑 A crítica reprovou repetidas vezes e a label \`${LABEL_NEEDS_HUMAN}\` foi aplicada. ` +
|
|
40
|
+
'Uma pessoa precisa revisar o `bug.md` e remover a label.'
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!existsSync(fileAbs)) {
|
|
45
|
+
errors.push(
|
|
46
|
+
`❌ \`bug.md\` não encontrado em \`${fileRel}\` — aplique \`${LABEL_BUG}\` para gerá-lo.`
|
|
47
|
+
);
|
|
48
|
+
} else {
|
|
49
|
+
const content = readFileSync(fileAbs, 'utf-8');
|
|
50
|
+
for (const section of findMissingSections(content, REQUIRED_BUG_SECTIONS)) {
|
|
51
|
+
errors.push(`❌ Seção obrigatória ausente no bug.md: **${section}**`);
|
|
52
|
+
}
|
|
53
|
+
for (const problem of findIncompleteDocSigns(content)) {
|
|
54
|
+
errors.push(`❌ \`bug.md\` parece incompleto: ${problem}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
await removeLabel(token, owner, repo, n, 'spec-wave:ready');
|
|
59
|
+
|
|
60
|
+
if (errors.length > 0) {
|
|
61
|
+
await commentOnIssue(
|
|
62
|
+
token, owner, repo, n,
|
|
63
|
+
'⚠️ **Validação falhou — o bug.md não está pronto.**\n\n' +
|
|
64
|
+
errors.join('\n') +
|
|
65
|
+
`\n\nCorrija os problemas e adicione novamente a label \`spec-wave:ready\`.`
|
|
66
|
+
);
|
|
67
|
+
console.error('Validação falhou:', errors.join(', '));
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
await addLabel(token, owner, repo, n, LABEL_BUG_APPROVED);
|
|
72
|
+
await commentOnIssue(
|
|
73
|
+
token, owner, repo, n,
|
|
74
|
+
'✅ **bug.md validado.**\n\n' +
|
|
75
|
+
`📄 [\`${fileRel}\`](https://github.com/${owner}/${repo}/blob/main/${fileRel})\n\n` +
|
|
76
|
+
'As seis seções obrigatórias estão presentes — reprodução, causa raiz e teste de ' +
|
|
77
|
+
'regressão inclusive. O bug pode ser aceito na triagem.'
|
|
78
|
+
);
|
|
79
|
+
console.log('bug.md validado.');
|
|
80
|
+
}
|
|
81
|
+
|
|
13
82
|
export async function validate({ issueNumber }) {
|
|
14
83
|
const token = await resolveToken();
|
|
15
84
|
const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
@@ -25,6 +94,13 @@ export async function validate({ issueNumber }) {
|
|
|
25
94
|
}
|
|
26
95
|
|
|
27
96
|
const issue = await getIssue(token, owner, repo, parseInt(issueNumber, 10));
|
|
97
|
+
|
|
98
|
+
// Bug tem artefato próprio (bug.md) e caminho próprio de validação — não passa
|
|
99
|
+
// pelo par spec.md + plan.md, que é exclusivo de Feature.
|
|
100
|
+
if (detectIssueType(issue) === 'Bug') {
|
|
101
|
+
return await validateBug({ token, owner, repo, issue, issueNumber, root });
|
|
102
|
+
}
|
|
103
|
+
|
|
28
104
|
const slug = slugify(issue.title);
|
|
29
105
|
// Ancorado na RAIZ do repo, não no cwd: rodar de um subdiretório encontra o
|
|
30
106
|
// config subindo na árvore e precisa encontrar os documentos no mesmo lugar.
|
|
@@ -57,10 +133,8 @@ export async function validate({ issueNumber }) {
|
|
|
57
133
|
errors.push('❌ `plan.md` não encontrado em `' + `${featureRel}/plan.md` + '`');
|
|
58
134
|
} else {
|
|
59
135
|
const planContent = readFileSync(planPath, 'utf-8');
|
|
60
|
-
for (const section of REQUIRED_PLAN_SECTIONS) {
|
|
61
|
-
|
|
62
|
-
errors.push(`❌ Seção obrigatória ausente no plan.md: **${section}**`);
|
|
63
|
-
}
|
|
136
|
+
for (const section of findMissingSections(planContent, REQUIRED_PLAN_SECTIONS)) {
|
|
137
|
+
errors.push(`❌ Seção obrigatória ausente no plan.md: **${section}**`);
|
|
64
138
|
}
|
|
65
139
|
for (const problem of findIncompleteDocSigns(planContent)) {
|
|
66
140
|
errors.push(`❌ \`plan.md\` parece incompleto: ${problem}`);
|
|
@@ -73,10 +147,8 @@ export async function validate({ issueNumber }) {
|
|
|
73
147
|
errors.push('❌ `spec.md` não encontrado em `' + `${featureRel}/spec.md` + '`');
|
|
74
148
|
} else {
|
|
75
149
|
const specContent = readFileSync(specPath, 'utf-8');
|
|
76
|
-
for (const section of REQUIRED_SPEC_SECTIONS) {
|
|
77
|
-
|
|
78
|
-
errors.push(`❌ Seção obrigatória ausente no spec.md: **${section}**`);
|
|
79
|
-
}
|
|
150
|
+
for (const section of findMissingSections(specContent, REQUIRED_SPEC_SECTIONS)) {
|
|
151
|
+
errors.push(`❌ Seção obrigatória ausente no spec.md: **${section}**`);
|
|
80
152
|
}
|
|
81
153
|
// Seções presentes não garantem documento completo: um corte dentro da
|
|
82
154
|
// última seção passa na checagem acima (foi o caso da EP2-F13).
|