@spec-wave/cli 0.13.0 → 0.15.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 (38) hide show
  1. package/README.md +39 -6
  2. package/bin/spec-wave.mjs +17 -1
  3. package/package.json +1 -1
  4. package/src/api/github-rest.mjs +198 -2
  5. package/src/commands/code-review.mjs +5 -8
  6. package/src/commands/decompose.mjs +412 -130
  7. package/src/commands/dev-agent.mjs +3 -2
  8. package/src/commands/doctor.mjs +239 -9
  9. package/src/commands/generate-plan.mjs +105 -30
  10. package/src/commands/generate-spec.mjs +17 -5
  11. package/src/commands/implement.mjs +39 -15
  12. package/src/commands/info.mjs +4 -3
  13. package/src/commands/issue.mjs +4 -4
  14. package/src/commands/move.mjs +162 -0
  15. package/src/commands/order.mjs +1 -12
  16. package/src/commands/qa.mjs +5 -8
  17. package/src/commands/refresh.mjs +4 -3
  18. package/src/commands/story.mjs +1 -12
  19. package/src/commands/task.mjs +1 -11
  20. package/src/commands/update.mjs +372 -71
  21. package/src/commands/validate.mjs +37 -22
  22. package/src/config.mjs +40 -1
  23. package/src/lib/board.mjs +88 -26
  24. package/src/lib/claude.mjs +315 -70
  25. package/src/lib/critique.mjs +391 -91
  26. package/src/lib/decomposition-doc.mjs +451 -0
  27. package/src/lib/implement-board.mjs +14 -1
  28. package/src/lib/pr-branch.mjs +267 -0
  29. package/src/lib/project-root.mjs +93 -0
  30. package/src/lib/templates.mjs +53 -0
  31. package/src/setup/files.mjs +3 -10
  32. package/src/templates/skill/SKILL.md +158 -30
  33. package/src/templates/workflows/code-review.yml +1 -1
  34. package/src/templates/workflows/decompose.yml +20 -6
  35. package/src/templates/workflows/generate-plan.yml +1 -1
  36. package/src/templates/workflows/generate-spec.yml +1 -1
  37. package/src/templates/workflows/qa.yml +1 -1
  38. package/src/templates/workflows/validate.yml +1 -1
@@ -1,25 +1,28 @@
1
1
  import * as p from '@clack/prompts';
2
2
  import chalk from 'chalk';
3
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
4
- import { fileURLToPath } from 'node:url';
3
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
5
4
  import { homedir } from 'node:os';
6
5
  import path from 'node:path';
7
6
  import { resolveToken } from '../api/auth.mjs';
8
7
  import { CONFIG_FILE, WORKFLOW_FILES, ISSUE_TEMPLATE_FILES, ALL_LABELS } from '../config.mjs';
9
8
  import { getProjectSnapshot } from '../api/github-graphql.mjs';
10
9
  import {
11
- getFileContent, upsertFile, listLabels, createLabel, updateLabel,
10
+ getFileContent, upsertFile, listLabels, createLabel, updateLabel, deleteLabel,
11
+ getRepoDefaultBranch, compareBranches, commitFilesToBranch, ensurePullRequest,
12
12
  } from '../api/github-rest.mjs';
13
+ import {
14
+ resolveBranchName, composePrTitle, composePrBody, buildCommitMessage,
15
+ decideConfigInPr, explainGitWriteError,
16
+ } from '../lib/pr-branch.mjs';
17
+ // MESMO readTemplate do init: resolve {{CLI_VERSION}} antes da comparação byte a
18
+ // byte com o remoto. Se só o init resolvesse, todo update veria os workflows
19
+ // como desatualizados para sempre.
20
+ import { readTemplate } from '../lib/templates.mjs';
13
21
  import {
14
22
  TARGETS, SKILL_SOURCE, CLI_VERSION, parseSkill, renderContent,
15
23
  mergeAgentsFile, resolveDest, isDetected, skillCopyReason,
16
24
  } from './install-skill.mjs';
17
-
18
- const __dir = path.dirname(fileURLToPath(import.meta.url));
19
- const TEMPLATES_DIR = path.join(__dir, '..', 'templates');
20
- function readTemplate(...parts) {
21
- return readFileSync(path.join(TEMPLATES_DIR, ...parts), 'utf-8');
22
- }
25
+ import { findConfigPath } from '../lib/project-root.mjs';
23
26
 
24
27
  // Arquivos do repo gerenciados pela CLI (comparados com o template empacotado).
25
28
  const REPO_FILES = [
@@ -52,10 +55,23 @@ function applySkill(job) {
52
55
  writeFileSync(job.dest.path, content, 'utf-8');
53
56
  }
54
57
 
55
- // Compara ALL_LABELS com as labels do repo. color no config é hex maiúsculo; a
56
- // API retorna minúsculo daí o toLowerCase() na comparação.
57
- function diffLabels(existing) {
58
- const byName = new Map(existing.map(l => [l.name, l]));
58
+ /**
59
+ * Compara ALL_LABELS com as labels do repo (função PURA — testável).
60
+ *
61
+ * `color` no config é hex maiúsculo e a API retorna minúsculo — daí o
62
+ * toLowerCase() na comparação.
63
+ *
64
+ * `orphan` são labels `spec-wave:*` que existem no repo mas saíram do config: o
65
+ * update só sabia criar e atualizar, nunca remover, então uma label descontinuada
66
+ * ficava para sempre — foi o caso da `spec-wave:force`, removida do código na
67
+ * 0.13.0 mas ainda presente em todo repo inicializado com a 0.12.0. Só o
68
+ * namespace `spec-wave:` é considerado: labels do time não são da nossa conta.
69
+ *
70
+ * @param {Array<{name,color,description}>} existing labels do repo
71
+ * @returns {{ missing: object[], changed: object[], orphan: object[] }}
72
+ */
73
+ export function diffLabels(existing) {
74
+ const byName = new Map((existing || []).map(l => [l.name, l]));
59
75
  const missing = [];
60
76
  const changed = [];
61
77
  for (const label of ALL_LABELS) {
@@ -63,13 +79,90 @@ function diffLabels(existing) {
63
79
  if (!cur) {
64
80
  missing.push(label);
65
81
  } else if (
66
- cur.color.toLowerCase() !== label.color.toLowerCase() ||
82
+ (cur.color || '').toLowerCase() !== label.color.toLowerCase() ||
67
83
  (cur.description || '') !== (label.description || '')
68
84
  ) {
69
85
  changed.push(label);
70
86
  }
71
87
  }
72
- return { missing, changed };
88
+ const known = new Set(ALL_LABELS.map(l => l.name));
89
+ const orphan = (existing || []).filter(l => l.name?.startsWith('spec-wave:') && !known.has(l.name));
90
+ return { missing, changed, orphan };
91
+ }
92
+
93
+ /**
94
+ * Regenera o conteúdo do .spec-wave.json a partir do Project remoto SEM GRAVAR.
95
+ *
96
+ * A geração foi separada da gravação porque no modo --branch esse conteúdo pode
97
+ * precisar entrar na árvore do commit — e a árvore é montada antes de QUALQUER
98
+ * escrita, para que uma falha no caminho não deixe nada aplicado, nem local nem
99
+ * remoto.
100
+ *
101
+ * @returns {Promise<{ content: string|null, error: string|null }>}
102
+ */
103
+ async function buildConfigContent(token, config) {
104
+ const snapshot = await getProjectSnapshot(token, config.project.id);
105
+ if (!snapshot) return { content: null, error: 'Project não encontrado' };
106
+ const { etapaFieldId: _e, stageOptions: _s, ...projectRest } = config.project;
107
+ const updated = {
108
+ ...config,
109
+ version: CLI_VERSION,
110
+ project: {
111
+ ...projectRest,
112
+ title: snapshot.title,
113
+ url: snapshot.url,
114
+ id: snapshot.id,
115
+ number: snapshot.number,
116
+ fields: snapshot.fields,
117
+ },
118
+ refreshedAt: new Date().toISOString(),
119
+ };
120
+ // MESMA serialização do init (2 espaços + \n final): este texto é comparado
121
+ // byte a byte com o remoto em decideConfigInPr, e um \n de diferença faria o
122
+ // config entrar no PR em toda execução.
123
+ return { content: `${JSON.stringify(updated, null, 2)}\n`, error: null };
124
+ }
125
+
126
+ /**
127
+ * Aplica o diff de labels direto na base.
128
+ *
129
+ * Label NUNCA entra num PR: é metadado do repositório, não arquivo versionado —
130
+ * não existe forma de propor a mudança para revisão.
131
+ *
132
+ * @returns {Promise<{created: string[], updated: string[], removed: string[]}>}
133
+ * só o que REALMENTE passou — é o que o corpo do PR vai afirmar a quem
134
+ * revisa, e ele não pode prometer o que falhou.
135
+ */
136
+ async function applyLabels(token, owner, repo, labelDiff) {
137
+ const done = { created: [], updated: [], removed: [] };
138
+ for (const label of labelDiff.missing) {
139
+ try {
140
+ await createLabel(token, owner, repo, label);
141
+ done.created.push(label.name);
142
+ p.log.success(`Label criada: ${label.name}`);
143
+ } catch (err) {
144
+ p.log.error(`Falha ao criar label ${label.name}: ${err.message}`);
145
+ }
146
+ }
147
+ for (const label of labelDiff.changed) {
148
+ try {
149
+ await updateLabel(token, owner, repo, label);
150
+ done.updated.push(label.name);
151
+ p.log.success(`Label atualizada: ${label.name}`);
152
+ } catch (err) {
153
+ p.log.error(`Falha ao atualizar label ${label.name}: ${err.message}`);
154
+ }
155
+ }
156
+ for (const label of labelDiff.orphan) {
157
+ try {
158
+ await deleteLabel(token, owner, repo, label.name);
159
+ done.removed.push(label.name);
160
+ p.log.success(`Label descontinuada removida: ${label.name}`);
161
+ } catch (err) {
162
+ p.log.error(`Falha ao remover label ${label.name}: ${err.message}`);
163
+ }
164
+ }
165
+ return done;
73
166
  }
74
167
 
75
168
  export async function update(options = {}) {
@@ -78,17 +171,34 @@ export async function update(options = {}) {
78
171
  const isGlobal = !!options.global;
79
172
  const baseDir = isGlobal ? homedir() : process.cwd();
80
173
 
174
+ // --branch: valida o nome ANTES de qualquer rede. Descobrir um espaço no nome
175
+ // só no createRef, depois de meia dúzia de requisições, é o pior desperdício
176
+ // possível — e a mensagem crua da API não aponta a causa.
177
+ const prRequested = options.branch !== undefined && options.branch !== false;
178
+ let branch = null;
179
+ if (prRequested) {
180
+ const r = resolveBranchName(options.branch, { version: CLI_VERSION });
181
+ if (r.error) {
182
+ p.log.error(r.error);
183
+ p.cancel('Update cancelado.');
184
+ return;
185
+ }
186
+ branch = r.branch;
187
+ }
188
+
81
189
  // ---------- Detecção ----------
82
190
  // 1) Skill (por agente detectado).
83
191
  const parsed = parseSkill(readFileSync(SKILL_SOURCE, 'utf-8'));
84
192
  const skillJobs = options.skipSkill ? [] : detectSkill(parsed, baseDir, isGlobal);
85
193
 
86
194
  // 2) Config + repo dependem do .spec-wave.json local do repo atual.
87
- const configPath = path.join(process.cwd(), CONFIG_FILE);
195
+ const configPath = findConfigPath();
88
196
  let config = null;
89
- if (existsSync(configPath)) {
197
+ let rawConfig = null; // texto cru: comparado com o remoto em decideConfigInPr
198
+ if (configPath) {
90
199
  try {
91
- config = JSON.parse(readFileSync(configPath, 'utf-8'));
200
+ rawConfig = readFileSync(configPath, 'utf-8');
201
+ config = JSON.parse(rawConfig);
92
202
  } catch (err) {
93
203
  p.log.warn(`${CONFIG_FILE} corrompido (${err.message}); pulando config/repo.`);
94
204
  }
@@ -123,8 +233,10 @@ export async function update(options = {}) {
123
233
  }
124
234
 
125
235
  // 2b) Arquivos do repo e labels divergentes (exige token + rede).
236
+ const owner = config?.owner;
237
+ const repo = config?.repo;
126
238
  let repoFiles = [];
127
- let labelDiff = { missing: [], changed: [] };
239
+ let labelDiff = { missing: [], changed: [], orphan: [] };
128
240
  let repoChecked = false;
129
241
  if (doRepo) {
130
242
  const s = p.spinner();
@@ -134,7 +246,6 @@ export async function update(options = {}) {
134
246
  s.stop('');
135
247
  p.log.warn(`Sem token do GitHub (${tokenError?.message ?? 'indisponível'}); pulando verificação do repo.`);
136
248
  } else {
137
- const { owner, repo } = config;
138
249
  try {
139
250
  for (const f of REPO_FILES) {
140
251
  const remote = await getFileContent(tk, owner, repo, f.repoPath);
@@ -152,9 +263,67 @@ export async function update(options = {}) {
152
263
  }
153
264
  }
154
265
 
266
+ // 2c) Modo PR: base remota, estado do config na base e situação da branch.
267
+ // TUDO leitura — precisa acontecer aqui porque o resumo e o --dry-run já
268
+ // mostram a branch, a base e se o config vai no PR.
269
+ let prMode = prRequested && doRepo && repoChecked;
270
+ let base = null;
271
+ let remoteConfigRaw = null;
272
+ let branchExists = false;
273
+ if (prMode) {
274
+ const tk = await getToken();
275
+ try {
276
+ base = await getRepoDefaultBranch(tk, owner, repo);
277
+ if (branch === base) {
278
+ p.log.error(
279
+ `--branch "${branch}" é a própria branch default do repositório; ` +
280
+ 'um Pull Request precisa de uma branch diferente da base.'
281
+ );
282
+ p.cancel('Update cancelado.');
283
+ return;
284
+ }
285
+ remoteConfigRaw = await getFileContent(tk, owner, repo, CONFIG_FILE, base);
286
+ const status = await compareBranches(tk, owner, repo, base, branch);
287
+ branchExists = status !== null;
288
+ if (status === 'behind' || status === 'identical') {
289
+ // Não fazemos force-push: só avisamos. Empilhar um commit numa branch já
290
+ // mergeada funciona, mas o diff do PR pode reexibir arquivos que já estão
291
+ // na base (caso do squash merge).
292
+ p.log.warn(
293
+ `A branch "${branch}" já existe e não tem nenhum commit além de "${base}" ` +
294
+ '(provavelmente de um PR já mergeado). O commit novo seria empilhado nela — ' +
295
+ 'use `--branch <outro-nome>` para começar do zero.'
296
+ );
297
+ }
298
+ } catch (err) {
299
+ p.log.warn(
300
+ `Não foi possível preparar o modo PR: ${err.message} — ` +
301
+ 'os arquivos seguiriam por commit direto.'
302
+ );
303
+ prMode = false;
304
+ }
305
+ }
306
+
307
+ // Decisão preliminar sobre o config. O conteúdo regenerado ainda não existe;
308
+ // `willRegenerate` cobre isso, porque o regenerado SEMPRE difere do remoto
309
+ // (refreshedAt muda a cada execução). Na aplicação a decisão é recalculada com
310
+ // o conteúdo real.
311
+ const configDecision = prMode && doConfig
312
+ ? decideConfigInPr({
313
+ remote: remoteConfigRaw,
314
+ desired: rawConfig,
315
+ willRegenerate: !!configStale?.canApply,
316
+ force: options.configInPr,
317
+ })
318
+ : { included: false, reason: '' };
319
+
155
320
  // ---------- Resumo ----------
156
- const labelTotal = labelDiff.missing.length + labelDiff.changed.length;
157
- const total = skillJobs.length + (configStale ? 1 : 0) + repoFiles.length + labelTotal;
321
+ const labelTotal = labelDiff.missing.length + labelDiff.changed.length + labelDiff.orphan.length;
322
+ // Config em dia pela versão, mas divergente do que está na base: antes isso dava
323
+ // "tudo atualizado" e o repositório ficava com um config velho indefinidamente.
324
+ const configOnlyPr = configDecision.included && !configStale;
325
+ const total = skillJobs.length + (configStale ? 1 : 0) + repoFiles.length + labelTotal
326
+ + (configOnlyPr ? 1 : 0);
158
327
 
159
328
  if (total === 0) {
160
329
  p.log.success('Tudo já está atualizado para a versão atual da CLI.');
@@ -173,23 +342,72 @@ export async function update(options = {}) {
173
342
  (configStale.canApply ? '' : chalk.dim(' (sem project.id — rode `init` sem --skip-project)')));
174
343
  }
175
344
  if (repoFiles.length) {
176
- lines.push(chalk.bold('Arquivos do repo:'));
345
+ lines.push(prMode
346
+ ? chalk.bold(`Arquivos do repo → Pull Request (${branch} → ${base}):`)
347
+ : chalk.bold('Arquivos do repo:'));
177
348
  for (const f of repoFiles) lines.push(` ${chalk.yellow('↻')} ${f.repoPath} (${f.reason})`);
178
349
  }
350
+ if (prMode && configDecision.included) {
351
+ if (!repoFiles.length) lines.push(chalk.bold(`Pull Request (${branch} → ${base}):`));
352
+ lines.push(` ${chalk.yellow('↻')} ${CONFIG_FILE} (${configDecision.reason})`);
353
+ }
354
+ if (prMode && doConfig && !configDecision.included && configDecision.reason) {
355
+ lines.push(chalk.dim(` · ${CONFIG_FILE} fora do PR — ${configDecision.reason}`));
356
+ }
179
357
  if (labelTotal) {
180
- lines.push(chalk.bold('Labels:'));
358
+ lines.push(prMode
359
+ ? chalk.bold(`Labels (direto em ${base} — metadado do repo, não versionável):`)
360
+ : chalk.bold('Labels:'));
181
361
  if (labelDiff.missing.length) lines.push(` ${chalk.yellow('+')} criar: ${labelDiff.missing.map(l => l.name).join(', ')}`);
182
362
  if (labelDiff.changed.length) lines.push(` ${chalk.yellow('↻')} atualizar: ${labelDiff.changed.map(l => l.name).join(', ')}`);
363
+ if (labelDiff.orphan.length) {
364
+ lines.push(` ${chalk.red('−')} remover (descontinuadas): ${labelDiff.orphan.map(l => l.name).join(', ')}`);
365
+ }
183
366
  }
184
367
  p.note(lines.join('\n'), `${total} item(ns) desatualizado(s)`);
185
368
 
369
+ // --branch sem efeito: melhor dizer POR QUE do que criar uma branch inútil.
370
+ const prHasPayload = prMode && (repoFiles.length > 0 || configDecision.included);
371
+ if (prRequested && options.skipRepo) {
372
+ p.log.warn(
373
+ '--branch ignorado junto com --skip-repo: o Pull Request existe justamente para ' +
374
+ 'levar os arquivos do repo.'
375
+ );
376
+ } else if (prRequested && !prMode) {
377
+ p.log.warn(
378
+ '--branch ignorado: sem token, sem owner/repo no .spec-wave.json ou a comparação com ' +
379
+ 'o repositório falhou — não há como abrir o Pull Request.'
380
+ );
381
+ } else if (prMode && !prHasPayload) {
382
+ p.log.warn(
383
+ '--branch sem efeito: nenhum arquivo do repositório para enviar (as mudanças são ' +
384
+ 'locais e/ou de labels). Nenhuma branch será criada.'
385
+ );
386
+ }
387
+
186
388
  if (options.dryRun) {
389
+ if (prHasPayload) {
390
+ const n = repoFiles.length + (configDecision.included ? 1 : 0);
391
+ p.note(
392
+ `Branch: ${branch}${branchExists ? ' (já existe — o commit seria empilhado nela)' : ' (seria criada)'}\n` +
393
+ `Base: ${base}\n` +
394
+ `Título: ${composePrTitle({ version: CLI_VERSION })}\n` +
395
+ `Arquivos: ${n} em 1 commit único\n\n` +
396
+ 'Nada foi criado: nem branch, nem commit, nem Pull Request.',
397
+ 'Dry-run: modo Pull Request'
398
+ );
399
+ }
187
400
  p.outro('Dry-run: nada foi alterado.');
188
401
  return;
189
402
  }
190
403
 
191
404
  if (!options.yes) {
192
- const ok = await p.confirm({ message: `Aplicar as ${total} atualização(ões)?`, initialValue: true });
405
+ const ok = await p.confirm({
406
+ message: prHasPayload
407
+ ? `Aplicar as ${total} atualização(ões) e abrir um Pull Request em "${branch}"?`
408
+ : `Aplicar as ${total} atualização(ões)?`,
409
+ initialValue: true,
410
+ });
193
411
  if (p.isCancel(ok) || !ok) {
194
412
  p.cancel('Update cancelado.');
195
413
  return;
@@ -197,6 +415,14 @@ export async function update(options = {}) {
197
415
  }
198
416
 
199
417
  // ---------- Aplicação ----------
418
+ // Ordem: skill (local) → config GERADO em memória → labels (sempre direto) →
419
+ // arquivos do repo (PR ou commits diretos) → gravação local do config.
420
+ //
421
+ // As labels vêm ANTES dos arquivos porque o corpo do PR precisa dizer quais
422
+ // labels JÁ mudaram na base — é a única coisa que o update faz e o diff do PR
423
+ // não mostra. (Isso muda a ordem dos logs também no modo direto; os dois blocos
424
+ // são independentes.)
425
+
200
426
  // Skill
201
427
  for (const job of skillJobs) {
202
428
  try {
@@ -207,7 +433,8 @@ export async function update(options = {}) {
207
433
  }
208
434
  }
209
435
 
210
- // Config (.spec-wave.json) — reconsulta o Project e reescreve local.
436
+ // Config (.spec-wave.json) — regenera o conteúdo, ainda sem tocar o disco.
437
+ let configContent = null;
211
438
  if (configStale) {
212
439
  if (!configStale.canApply) {
213
440
  p.log.warn(`${CONFIG_FILE}: sem project.id — pulei. Rode \`npx @spec-wave/cli@latest init\` (sem --skip-project).`);
@@ -217,75 +444,149 @@ export async function update(options = {}) {
217
444
  p.log.warn(`${CONFIG_FILE}: sem token — pulei. (${tokenError?.message ?? ''})`);
218
445
  } else {
219
446
  const s = p.spinner();
220
- s.start('Atualizando .spec-wave.json...');
447
+ s.start('Consultando o Project para atualizar o .spec-wave.json...');
221
448
  try {
222
- const snapshot = await getProjectSnapshot(tk, config.project.id);
223
- if (!snapshot) throw new Error('Project não encontrado');
224
- const { etapaFieldId: _e, stageOptions: _s, ...projectRest } = config.project;
225
- const updated = {
226
- ...config,
227
- version: CLI_VERSION,
228
- project: {
229
- ...projectRest,
230
- title: snapshot.title,
231
- url: snapshot.url,
232
- id: snapshot.id,
233
- number: snapshot.number,
234
- fields: snapshot.fields,
235
- },
236
- refreshedAt: new Date().toISOString(),
237
- };
238
- writeFileSync(configPath, JSON.stringify(updated, null, 2) + '\n');
239
- s.stop(`${CONFIG_FILE} atualizado (v${CLI_VERSION}).`);
449
+ const { content, error } = await buildConfigContent(tk, config);
450
+ if (error) throw new Error(error);
451
+ configContent = content;
452
+ s.stop(`${CONFIG_FILE} regenerado (v${CLI_VERSION}).`);
240
453
  } catch (err) {
241
454
  s.stop('');
242
- p.log.error(`Falha ao atualizar ${CONFIG_FILE}: ${err.message}`);
455
+ p.log.error(`Falha ao regerar ${CONFIG_FILE}: ${err.message}`);
243
456
  }
244
457
  }
245
458
  }
246
459
  }
247
460
 
248
- // Arquivos do repo
249
- if (repoFiles.length) {
250
- const tk = await getToken();
251
- const { owner, repo } = config;
252
- for (const f of repoFiles) {
253
- try {
254
- await upsertFile(tk, owner, repo, f.repoPath, f.local, `chore: update ${path.basename(f.repoPath)} [spec-wave]`);
255
- p.log.success(`Arquivo atualizado no repo: ${f.repoPath}`);
256
- } catch (err) {
257
- p.log.error(`Falha ao atualizar ${f.repoPath}: ${err.message}`);
258
- }
461
+ // Recalcula a decisão com o conteúdo REAL (a preliminar usava willRegenerate).
462
+ const finalConfigDecision = prMode && doConfig
463
+ ? decideConfigInPr({
464
+ remote: remoteConfigRaw,
465
+ desired: configContent ?? rawConfig,
466
+ force: options.configInPr,
467
+ })
468
+ : { included: false, reason: '' };
469
+
470
+ // Gravação local do config, feita UMA vez só. No modo PR com o config a bordo
471
+ // ela é adiada para depois do commit remoto: assim uma falha no envio não deixa
472
+ // o arquivo local adiantado em relação ao repositório.
473
+ let configFlushed = false;
474
+ const flushConfig = () => {
475
+ if (configFlushed || !configContent) return;
476
+ try {
477
+ writeFileSync(configPath, configContent);
478
+ configFlushed = true;
479
+ p.log.success(`${CONFIG_FILE} atualizado localmente (v${CLI_VERSION}).`);
480
+ } catch (err) {
481
+ p.log.error(`Falha ao gravar ${CONFIG_FILE}: ${err.message}`);
259
482
  }
260
- }
483
+ };
484
+ if (!finalConfigDecision.included) flushConfig();
261
485
 
262
- // Labels
486
+ // Labels — sempre direto na base (metadado do repo, não versionável).
487
+ let labelResult = { created: [], updated: [], removed: [] };
263
488
  if (labelTotal) {
489
+ labelResult = await applyLabels(await getToken(), owner, repo, labelDiff);
490
+ }
491
+
492
+ // Arquivos do repo
493
+ let prUrl = null;
494
+ if (prMode) {
264
495
  const tk = await getToken();
265
- const { owner, repo } = config;
266
- for (const label of labelDiff.missing) {
496
+ const treeFiles = [
497
+ ...repoFiles.map(f => ({ path: f.repoPath, reason: f.reason, content: f.local })),
498
+ ...(finalConfigDecision.included
499
+ ? [{
500
+ path: CONFIG_FILE,
501
+ reason: finalConfigDecision.reason,
502
+ content: configContent ?? rawConfig,
503
+ }]
504
+ : []),
505
+ ];
506
+ if (!treeFiles.length) {
507
+ p.log.warn('Nada para enviar em um Pull Request: nenhum arquivo do repositório mudou.');
508
+ } else {
509
+ const s = p.spinner();
510
+ s.start(`Enviando ${treeFiles.length} arquivo(s) em um único commit para "${branch}"...`);
511
+ let commitOk = false;
267
512
  try {
268
- await createLabel(tk, owner, repo, label);
269
- p.log.success(`Label criada: ${label.name}`);
513
+ const res = await commitFilesToBranch(tk, owner, repo, {
514
+ branch,
515
+ base,
516
+ files: treeFiles.map(f => ({ path: f.path, content: f.content })),
517
+ message: buildCommitMessage({ version: CLI_VERSION, files: treeFiles }),
518
+ });
519
+ commitOk = true;
520
+ s.stop(res.unchanged
521
+ ? `"${branch}" já contém estas alterações — nenhum commit novo.`
522
+ : `Commit ${res.commitSha.slice(0, 7)} enviado para "${branch}" (${treeFiles.length} arquivo(s)).`);
270
523
  } catch (err) {
271
- p.log.error(`Falha ao criar label ${label.name}: ${err.message}`);
524
+ s.stop('');
525
+ p.log.error(`Falha ao enviar os arquivos para "${branch}": ${explainGitWriteError(err)}`);
526
+ }
527
+
528
+ // O PR fica num try SEPARADO: a falha aqui não invalida o commit, e a
529
+ // mensagem precisa dizer que a branch existe. Um token com "Contents:
530
+ // write" pode não ter "Pull requests: write" — permissão NOVA neste modo.
531
+ if (commitOk) {
532
+ try {
533
+ const pr = await ensurePullRequest(tk, owner, repo, {
534
+ branch,
535
+ base,
536
+ title: composePrTitle({ version: CLI_VERSION }),
537
+ body: composePrBody({
538
+ version: CLI_VERSION,
539
+ base,
540
+ branch,
541
+ files: treeFiles,
542
+ config: doConfig ? finalConfigDecision : null,
543
+ labels: labelResult,
544
+ skill: skillJobs.map(j => j.target.name),
545
+ }),
546
+ });
547
+ if (!pr) {
548
+ p.log.warn(
549
+ `A branch "${branch}" está em dia com "${base}" e não há PR aberto para ela — ` +
550
+ 'nada a revisar.'
551
+ );
552
+ } else {
553
+ prUrl = pr.url;
554
+ p.log.success(pr.created
555
+ ? `Pull Request aberto: #${pr.number} — ${pr.url}`
556
+ : `Pull Request já aberto para "${branch}": #${pr.number} — ${pr.url}`);
557
+ }
558
+ flushConfig();
559
+ } catch (err) {
560
+ p.log.error(`Commit enviado, mas não foi possível abrir o PR: ${explainGitWriteError(err)}`);
561
+ p.log.info(
562
+ 'Abra manualmente: ' +
563
+ `https://github.com/${owner}/${repo}/compare/${base}...${encodeURIComponent(branch)}?expand=1`
564
+ );
565
+ }
272
566
  }
273
567
  }
274
- for (const label of labelDiff.changed) {
568
+ } else if (repoFiles.length) {
569
+ const tk = await getToken();
570
+ for (const f of repoFiles) {
275
571
  try {
276
- await updateLabel(tk, owner, repo, label);
277
- p.log.success(`Label atualizada: ${label.name}`);
572
+ await upsertFile(tk, owner, repo, f.repoPath, f.local, `chore: update ${path.basename(f.repoPath)} [spec-wave]`);
573
+ p.log.success(`Arquivo atualizado no repo: ${f.repoPath}`);
278
574
  } catch (err) {
279
- p.log.error(`Falha ao atualizar label ${label.name}: ${err.message}`);
575
+ p.log.error(`Falha ao atualizar ${f.repoPath}: ${err.message}`);
280
576
  }
281
577
  }
282
578
  }
283
579
 
284
- const committedRepo = repoFiles.length > 0;
580
+ const configPending = configFlushed && !finalConfigDecision.included;
285
581
  p.outro(
286
582
  'Update concluído.' +
287
583
  (skillJobs.length ? ' Recarregue o agente para pegar a skill nova.' : '') +
288
- (configStale?.canApply ? ` Faça commit do ${CONFIG_FILE}.` : '') +
289
- (committedRepo ? ' Arquivos do repo foram commitados no remoto.' : '')
584
+ (prUrl ? ` Revise e faça o merge do Pull Request: ${prUrl}` : '') +
585
+ (!prMode && repoFiles.length ? ' Arquivos do repo foram commitados no remoto.' : '') +
586
+ (configPending ? ` Faça commit do ${CONFIG_FILE}.` : '') +
587
+ (finalConfigDecision.included
588
+ ? ` O ${CONFIG_FILE} local ficou igual ao do PR — depois do merge, descarte a cópia ` +
589
+ `local com \`git checkout -- ${CONFIG_FILE}\`.`
590
+ : '')
290
591
  );
291
592
  }