@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.
Files changed (76) hide show
  1. package/README.md +1 -0
  2. package/bin/spec-wave.mjs +44 -2
  3. package/package.json +8 -2
  4. package/src/agent/anthropic-agent.mjs +337 -0
  5. package/src/agent/errors.mjs +33 -0
  6. package/src/agent/index.mjs +108 -0
  7. package/src/agent/openrouter-agent.mjs +378 -0
  8. package/src/agent/run-types.mjs +59 -0
  9. package/src/agent/telemetry.mjs +54 -0
  10. package/src/agent/tools.mjs +452 -0
  11. package/src/agent/tracing.mjs +106 -0
  12. package/src/api/github-rest.mjs +206 -2
  13. package/src/commands/bug.mjs +8 -0
  14. package/src/commands/code-review.mjs +45 -4
  15. package/src/commands/decompose.mjs +11 -49
  16. package/src/commands/dev-agent.mjs +3 -3
  17. package/src/commands/doctor.mjs +77 -6
  18. package/src/commands/generate-bug.mjs +195 -0
  19. package/src/commands/generate-plan.mjs +6 -20
  20. package/src/commands/generate-spec.mjs +6 -22
  21. package/src/commands/implement.mjs +105 -2
  22. package/src/commands/init.mjs +3 -3
  23. package/src/commands/install-skill.mjs +72 -16
  24. package/src/commands/issue.mjs +9 -7
  25. package/src/commands/move.mjs +11 -1
  26. package/src/commands/qa.mjs +23 -2
  27. package/src/commands/refresh.mjs +145 -5
  28. package/src/commands/triage.mjs +174 -0
  29. package/src/commands/update.mjs +352 -62
  30. package/src/commands/validate.mjs +82 -10
  31. package/src/config.mjs +159 -1
  32. package/src/lib/bug-context.mjs +160 -0
  33. package/src/lib/bug-doc.mjs +51 -0
  34. package/src/lib/bug-triage.mjs +81 -0
  35. package/src/lib/claude.mjs +71 -254
  36. package/src/lib/critique.mjs +43 -30
  37. package/src/lib/implement-board.mjs +12 -1
  38. package/src/lib/plugin-skills.mjs +122 -0
  39. package/src/lib/pr-branch.mjs +267 -0
  40. package/src/lib/prompt-loader.mjs +257 -0
  41. package/src/lib/skill-file.mjs +35 -0
  42. package/src/plugin/.claude-plugin/plugin.json +20 -0
  43. package/src/plugin/README.md +73 -0
  44. package/src/plugin/skills/bug/SKILL.md +60 -0
  45. package/src/plugin/skills/bug/model-prompt.critique.md +48 -0
  46. package/src/plugin/skills/bug/model-prompt.md +74 -0
  47. package/src/plugin/skills/decompose/SKILL.md +111 -0
  48. package/src/plugin/skills/decompose/model-prompt.critique.md +46 -0
  49. package/src/plugin/skills/decompose/model-prompt.feature.md +69 -0
  50. package/src/plugin/skills/decompose/model-prompt.rfc.md +52 -0
  51. package/src/plugin/skills/doctor/SKILL.md +51 -0
  52. package/src/plugin/skills/fix-pr/SKILL.md +130 -0
  53. package/src/plugin/skills/implement/SKILL.md +102 -0
  54. package/src/plugin/skills/info/SKILL.md +40 -0
  55. package/src/plugin/skills/issue/SKILL.md +63 -0
  56. package/src/plugin/skills/move/SKILL.md +52 -0
  57. package/src/plugin/skills/order/SKILL.md +36 -0
  58. package/src/plugin/skills/plan/SKILL.md +53 -0
  59. package/src/plugin/skills/plan/model-prompt.critique.md +44 -0
  60. package/src/plugin/skills/plan/model-prompt.md +59 -0
  61. package/src/plugin/skills/plan/reference/tech-context.md +56 -0
  62. package/src/plugin/skills/ready/SKILL.md +44 -0
  63. package/src/plugin/skills/rfc/SKILL.md +47 -0
  64. package/src/plugin/skills/setup/SKILL.md +67 -0
  65. package/src/plugin/skills/spec/SKILL.md +37 -0
  66. package/src/plugin/skills/spec/model-prompt.md +61 -0
  67. package/src/plugin/skills/story/SKILL.md +49 -0
  68. package/src/plugin/skills/task/SKILL.md +41 -0
  69. package/src/plugin/skills/triage/SKILL.md +52 -0
  70. package/src/plugin/skills/uninstall/SKILL.md +43 -0
  71. package/src/plugin/skills/update/SKILL.md +51 -0
  72. package/src/plugin/skills/workflow/SKILL.md +154 -0
  73. package/src/templates/skill/SKILL.md +69 -7
  74. package/src/templates/workflows/generate-bug.yml +36 -0
  75. package/src/templates/workflows/validate.yml +2 -1
  76. package/src/ui/wizard.mjs +5 -2
@@ -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
- jobs.push({ target, dest, desired: renderContent(dest.format, parsed, CLI_VERSION), reason });
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
- config = JSON.parse(readFileSync(configPath, 'utf-8'));
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
- const total = skillJobs.length + (configStale ? 1 : 0) + repoFiles.length + labelTotal;
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(chalk.bold('Arquivos do repo:'));
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(chalk.bold('Labels:'));
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({ message: `Aplicar as ${total} atualização(ões)?`, initialValue: true });
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) — reconsulta o Project e reescreve local.
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('Atualizando .spec-wave.json...');
460
+ s.start('Consultando o Project para atualizar o .spec-wave.json...');
237
461
  try {
238
- const snapshot = await getProjectSnapshot(tk, config.project.id);
239
- if (!snapshot) throw new Error('Project não encontrado');
240
- const { etapaFieldId: _e, stageOptions: _s, ...projectRest } = config.project;
241
- const updated = {
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 atualizar ${CONFIG_FILE}: ${err.message}`);
468
+ p.log.error(`Falha ao regerar ${CONFIG_FILE}: ${err.message}`);
259
469
  }
260
470
  }
261
471
  }
262
472
  }
263
473
 
264
- // Arquivos do repo
265
- if (repoFiles.length) {
266
- const tk = await getToken();
267
- const { owner, repo } = config;
268
- for (const f of repoFiles) {
269
- try {
270
- await upsertFile(tk, owner, repo, f.repoPath, f.local, `chore: update ${path.basename(f.repoPath)} [spec-wave]`);
271
- p.log.success(`Arquivo atualizado no repo: ${f.repoPath}`);
272
- } catch (err) {
273
- p.log.error(`Falha ao atualizar ${f.repoPath}: ${err.message}`);
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 { owner, repo } = config;
282
- for (const label of labelDiff.missing) {
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 createLabel(tk, owner, repo, label);
285
- p.log.success(`Label criada: ${label.name}`);
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
- p.log.error(`Falha ao criar label ${label.name}: ${err.message}`);
537
+ s.stop('');
538
+ p.log.error(`Falha ao enviar os arquivos para "${branch}": ${explainGitWriteError(err)}`);
288
539
  }
289
- }
290
- for (const label of labelDiff.changed) {
291
- try {
292
- await updateLabel(tk, owner, repo, label);
293
- p.log.success(`Label atualizada: ${label.name}`);
294
- } catch (err) {
295
- p.log.error(`Falha ao atualizar label ${label.name}: ${err.message}`);
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
- for (const label of labelDiff.orphan) {
581
+ } else if (repoFiles.length) {
582
+ const tk = await getToken();
583
+ for (const f of repoFiles) {
299
584
  try {
300
- await deleteLabel(tk, owner, repo, label.name);
301
- p.log.success(`Label descontinuada removida: ${label.name}`);
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 remover label ${label.name}: ${err.message}`);
588
+ p.log.error(`Falha ao atualizar ${f.repoPath}: ${err.message}`);
304
589
  }
305
590
  }
306
591
  }
307
592
 
308
- const committedRepo = repoFiles.length > 0;
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
- (configStale?.canApply ? ` Faça commit do ${CONFIG_FILE}.` : '') +
313
- (committedRepo ? ' Arquivos do repo foram commitados no remoto.' : '')
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
- if (!planContent.includes(`# ${section}`)) {
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
- if (!specContent.includes(`# ${section}`)) {
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).