@spec-wave/cli 0.27.0 → 0.29.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/package.json +1 -1
  2. package/src/api/github-graphql.mjs +37 -0
  3. package/src/api/github-rest.mjs +48 -0
  4. package/src/cli.mjs +51 -2
  5. package/src/commands/audit.mjs +280 -0
  6. package/src/commands/doctor.mjs +40 -16
  7. package/src/commands/implement.mjs +19 -2
  8. package/src/commands/install-skill.mjs +18 -8
  9. package/src/commands/merge.mjs +292 -0
  10. package/src/commands/move.mjs +26 -11
  11. package/src/commands/order.mjs +42 -0
  12. package/src/commands/preflight.mjs +322 -0
  13. package/src/commands/run.mjs +4 -3
  14. package/src/commands/update.mjs +143 -12
  15. package/src/lib/board.mjs +18 -2
  16. package/src/lib/critique.mjs +64 -8
  17. package/src/lib/pr-branch.mjs +96 -7
  18. package/src/lib/pr-step.mjs +12 -7
  19. package/src/lib/spec-audit.mjs +372 -0
  20. package/src/lib/tech-context.mjs +20 -14
  21. package/src/plugin/.claude-plugin/plugin.json +1 -1
  22. package/src/plugin/README.md +5 -0
  23. package/src/plugin/skills/audit/SKILL.md +34 -0
  24. package/src/plugin/skills/audit/model-prompt.critique.md +34 -0
  25. package/src/plugin/skills/merge/SKILL.md +34 -0
  26. package/src/plugin/skills/order/SKILL.md +1 -0
  27. package/src/plugin/skills/plan/model-prompt.md +1 -0
  28. package/src/plugin/skills/plan/reference/tech-context.md +6 -0
  29. package/src/plugin/skills/preparar-feature/SKILL.md +247 -0
  30. package/src/plugin/skills/preparar-feature/reference/critica.md +88 -0
  31. package/src/plugin/skills/preparar-specs/SKILL.md +191 -0
  32. package/src/plugin/skills/preparar-specs/reference/armadilhas.md +209 -0
  33. package/src/plugin/skills/preparar-specs/reference/revisao.md +110 -0
  34. package/src/plugin/skills/update/SKILL.md +10 -4
  35. package/src/plugin/skills/workflow/SKILL.md +6 -1
  36. package/src/templates/config/tech_context.yml +13 -0
  37. package/src/templates/skill/SKILL.md +36 -7
  38. package/src/templates/workflows/qa.yml +9 -1
@@ -0,0 +1,322 @@
1
+ // Preflight de uma rodada de geração de specs por milestone.
2
+ //
3
+ // Existe porque **cada geração paga um modelo**: descobrir na sétima Feature que
4
+ // a credencial estava errada custa sete gerações. Este comando levanta de uma
5
+ // vez tudo que decide a estratégia da rodada e tudo que costuma fazê-la falhar
6
+ // no fim — antes de gerar qualquer coisa.
7
+ //
8
+ // Ele NÃO é um segundo `doctor`. As verificações de ambiente reaproveitam os
9
+ // mesmos inspetores puros que o doctor usa (`describeModeState`,
10
+ // `inspectPrPublishing`); o que é novo aqui é o **inventário da milestone**:
11
+ // quais Features existem, e onde está o `spec.md` de cada uma.
12
+ //
13
+ // E "onde está" é a parte que não dá para improvisar com `existsSync`. Um
14
+ // documento recém-gerado vive numa branch `spec-wave/<n>-spec` que ninguém
15
+ // mergeou: quem procura só no disco o declara ausente e manda gerar de novo —
16
+ // pagando o modelo pela segunda vez pelo mesmo documento. Por isso o estado sai
17
+ // de `artifactStates`, que enxerga as quatro camadas.
18
+
19
+ import * as p from '@clack/prompts';
20
+ import chalk from 'chalk';
21
+
22
+ import { resolveToken } from '../api/auth.mjs';
23
+ import {
24
+ getRepoDefaultBranch, getRepoVariable, listMilestones, listIssuesByMilestone,
25
+ } from '../api/github-rest.mjs';
26
+ import { CONFIG_FILE } from '../config.mjs';
27
+ import { inspectPrPublishing, readPrPublishingContext } from './doctor.mjs';
28
+ import { configuredMode, describeModeState, EXECUTION_VARIABLE } from '../lib/execution-mode.mjs';
29
+ import { detectIssueType } from '../lib/issue-type.mjs';
30
+ import { isAwaitingMerge, artifactStates } from '../lib/doc-source.mjs';
31
+ import { featureDocPaths } from '../lib/doc-paths.mjs';
32
+ import { STEPS } from '../lib/next-step.mjs';
33
+ import { loadConfig } from '../lib/project-root.mjs';
34
+ import { unguardedWorkflows } from './mode.mjs';
35
+
36
+ const TRIGGER_LABELS = Object.values(STEPS).map(s => s.trigger).filter(Boolean);
37
+
38
+ /**
39
+ * O inventário da milestone (função PURA — é onde mora a decisão).
40
+ *
41
+ * Separa as Features em quatro destinos porque cada um pede uma ação diferente,
42
+ * e confundi-los é caro nos dois sentidos: tratar `pending-pr` como ausente
43
+ * regera um documento já pago e ainda descarta a revisão em curso; tratar
44
+ * `unknown` (falha de rede) como pronto pula uma Feature que ninguém escreveu.
45
+ *
46
+ * @param {object} [a]
47
+ * @param {Array<{number:number,title:string,state?:string,labels?:Array}>} [a.issues]
48
+ * issues da milestone, como a API devolve (de qualquer tipo)
49
+ * @param {Record<number, {state: string, pr: object|null}>} [a.specStates]
50
+ * estado do `spec.md` por número de issue
51
+ * @returns {{ features: object[], gerar: object[], prontas: object[],
52
+ * aguardandoMerge: object[], indefinidas: object[], fechadas: object[],
53
+ * gatilhosPendentes: Array<{number:number,labels:string[]}> }}
54
+ */
55
+ export function inspectMilestone({ issues = [], specStates = {} } = {}) {
56
+ const features = (issues || [])
57
+ .filter(i => detectIssueType(i) === 'Feature')
58
+ .map(i => {
59
+ const { state = 'unknown', pr = null } = specStates[i.number] || {};
60
+ return {
61
+ number: i.number,
62
+ title: i.title,
63
+ closed: i.state === 'closed',
64
+ spec: state,
65
+ pr,
66
+ labels: (i.labels || []).map(l => (typeof l === 'string' ? l : l?.name)).filter(Boolean),
67
+ };
68
+ })
69
+ .sort((a, b) => a.number - b.number);
70
+
71
+ const abertas = features.filter(f => !f.closed);
72
+ return {
73
+ features,
74
+ fechadas: features.filter(f => f.closed),
75
+ gerar: abertas.filter(f => f.spec === 'missing'),
76
+ prontas: abertas.filter(f => f.spec === 'local' || f.spec === 'remote'),
77
+ aguardandoMerge: abertas.filter(f => isAwaitingMerge(f.spec)),
78
+ indefinidas: abertas.filter(f => f.spec === 'unknown'),
79
+ // Label de gatilho grudada significa Action em execução — ou uma que falhou e
80
+ // a deixou para trás. Nos dois casos o `run` se recusa a executar (portão
81
+ // `trigger-pending`), então a rodada travaria Feature a Feature.
82
+ gatilhosPendentes: features
83
+ .map(f => ({ number: f.number, labels: f.labels.filter(l => TRIGGER_LABELS.includes(l)) }))
84
+ .filter(f => f.labels.length > 0),
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Resolve o título da milestone para o número que a API de issues aceita (PURA).
90
+ *
91
+ * Casamento exato primeiro; sem ele, um único casamento sem diferenciar
92
+ * maiúsculas. Ambíguo é erro, não escolha silenciosa: gerar as specs da
93
+ * milestone errada custa uma geração por Feature.
94
+ *
95
+ * @param {Array<{number:number,title:string,state?:string}>} milestones
96
+ * @param {string} titulo
97
+ * @returns {{ milestone: object|null, error: string|null }}
98
+ */
99
+ export function resolveMilestone(milestones = [], titulo = '') {
100
+ const alvo = String(titulo || '').trim();
101
+ const lista = () => (milestones.length
102
+ ? milestones.map(m => `"${m.title}"`).join(', ')
103
+ : '(o repositório não tem milestone nenhuma)');
104
+
105
+ if (!alvo) return { milestone: null, error: `Milestone não informada. Existem: ${lista()}.` };
106
+
107
+ const exata = milestones.find(m => m.title === alvo);
108
+ if (exata) return { milestone: exata, error: null };
109
+
110
+ const caseInsensitive = milestones.filter(m => m.title.toLowerCase() === alvo.toLowerCase());
111
+ if (caseInsensitive.length === 1) return { milestone: caseInsensitive[0], error: null };
112
+ if (caseInsensitive.length > 1) {
113
+ return { milestone: null, error: `Milestone "${alvo}" é ambígua entre ${lista()}.` };
114
+ }
115
+ return { milestone: null, error: `Milestone "${alvo}" não existe. Existem: ${lista()}.` };
116
+ }
117
+
118
+ /**
119
+ * Veredito final da rodada (função PURA).
120
+ *
121
+ * `bloqueios` impedem gerar; `avisos` não. A distinção é a razão de o comando
122
+ * existir: sair 1 por algo que não impede a geração faria o usuário aprender a
123
+ * ignorar o preflight, que é o mesmo que não o ter.
124
+ *
125
+ * @returns {{ status: 'ok'|'aviso'|'bloqueio', bloqueios: string[], avisos: string[] }}
126
+ */
127
+ export function inspectPreflight({
128
+ tokenOk = true, modo = null, publicacao = null, inventario = null,
129
+ } = {}) {
130
+ const bloqueios = [];
131
+ const avisos = [];
132
+
133
+ if (!tokenOk) bloqueios.push('Sem token utilizável para este repositório.');
134
+
135
+ if (modo?.status === 'problem') bloqueios.push(modo.summary);
136
+ else if (modo?.status === 'warn') avisos.push(modo.summary);
137
+
138
+ // A publicação por PR é o desfecho de TODA geração: sem ela o documento é
139
+ // gerado, o commit criado e nenhum PR aparece — a falha mais cara do fluxo,
140
+ // porque o modelo já foi pago quando ela acontece.
141
+ if (publicacao?.status === 'fail') bloqueios.push(...publicacao.notes);
142
+ else if (publicacao?.status === 'warn') avisos.push(...publicacao.notes);
143
+
144
+ if (inventario) {
145
+ if (inventario.features.length === 0) {
146
+ bloqueios.push('Nenhuma Feature [FEATURE] nesta milestone.');
147
+ } else if (inventario.gerar.length === 0) {
148
+ avisos.push('Nenhuma Feature pendente: todas já têm spec.md.');
149
+ }
150
+ if (inventario.gatilhosPendentes.length > 0) {
151
+ avisos.push(
152
+ `${inventario.gatilhosPendentes.length} issue(s) com label de gatilho pendente — ` +
153
+ 'o `run` se recusa a executar nelas até a label sair.'
154
+ );
155
+ }
156
+ if (inventario.aguardandoMerge.length > 0) {
157
+ avisos.push(
158
+ `${inventario.aguardandoMerge.length} spec(s) já geradas aguardando merge — ` +
159
+ 'NÃO as gere de novo: o documento existe e regerá-lo descarta a revisão.'
160
+ );
161
+ }
162
+ if (inventario.indefinidas.length > 0) {
163
+ avisos.push(
164
+ `${inventario.indefinidas.length} Feature(s) com estado indeterminado (falha de rede) — ` +
165
+ 'confirme antes de gerar, para não pagar duas vezes.'
166
+ );
167
+ }
168
+ }
169
+
170
+ if (bloqueios.length) return { status: 'bloqueio', bloqueios, avisos };
171
+ if (avisos.length) return { status: 'aviso', bloqueios, avisos };
172
+ return { status: 'ok', bloqueios, avisos };
173
+ }
174
+
175
+ const MARCA = { local: '✓ no disco', remote: '✓ na base', 'pending-pr': '⏳ em PR aberto', 'branch-only': '⚠ em branch sem PR', missing: '· gerar', unknown: '? indeterminado' };
176
+
177
+ export async function preflight({ milestone: milestoneArg, json = false } = {}) {
178
+ const saida = { milestone: null, modo: null, publicacao: null, inventario: null, veredito: null };
179
+ const falhar = (msg) => {
180
+ if (json) console.log(JSON.stringify({ ...saida, erro: msg }, null, 2));
181
+ else p.log.error(msg);
182
+ process.exitCode = 1;
183
+ };
184
+
185
+ if (!json) p.intro(chalk.bold('spec-wave preflight'));
186
+
187
+ const { config, root, error: configError } = loadConfig();
188
+ if (configError || !config?.owner || !config?.repo) {
189
+ return falhar(
190
+ `${configError || `${CONFIG_FILE} sem owner/repo`} — rode \`spec-wave init\` antes.`
191
+ );
192
+ }
193
+ const { owner, repo } = config;
194
+
195
+ let token;
196
+ try {
197
+ token = await resolveToken();
198
+ } catch (err) {
199
+ return falhar(`Sem token utilizável para ${owner}/${repo}: ${err.message}`);
200
+ }
201
+
202
+ const s = json ? null : p.spinner();
203
+ s?.start('Consultando o repositório...');
204
+
205
+ let base;
206
+ try {
207
+ base = await getRepoDefaultBranch(token, owner, repo);
208
+ } catch (err) {
209
+ s?.stop('');
210
+ return falhar(`Não foi possível ler o repositório ${owner}/${repo}: ${err.message}`);
211
+ }
212
+
213
+ // --- Modo de execução: config × variável (o mesmo par que o doctor confere).
214
+ let variable;
215
+ try {
216
+ variable = await getRepoVariable(token, owner, repo, EXECUTION_VARIABLE);
217
+ } catch {
218
+ variable = undefined; // exige admin — "não deu para ler" não é "ausente"
219
+ }
220
+ const modo = describeModeState({
221
+ configured: configuredMode(config),
222
+ variable,
223
+ unguardedWorkflows: unguardedWorkflows(root),
224
+ });
225
+
226
+ // --- Publicação por Pull Request: é o desfecho de toda geração.
227
+ const publicacao = inspectPrPublishing(
228
+ await readPrPublishingContext({ token, owner, repo, root })
229
+ );
230
+
231
+ // --- Inventário da milestone.
232
+ let milestones;
233
+ try {
234
+ milestones = await listMilestones(token, owner, repo);
235
+ } catch (err) {
236
+ s?.stop('');
237
+ return falhar(`Não foi possível listar as milestones: ${err.message}`);
238
+ }
239
+ const { milestone, error: msError } = resolveMilestone(milestones, milestoneArg);
240
+ if (msError) {
241
+ s?.stop('');
242
+ return falhar(msError);
243
+ }
244
+
245
+ let issues;
246
+ try {
247
+ issues = await listIssuesByMilestone(token, owner, repo, milestone.number);
248
+ } catch (err) {
249
+ s?.stop('');
250
+ return falhar(`Não foi possível listar as issues da milestone "${milestone.title}": ${err.message}`);
251
+ }
252
+
253
+ // O estado do spec.md de cada Feature, nas quatro camadas. Uma issue por vez:
254
+ // são até 3 requisições cada, e disparar tudo de uma vez numa milestone de 20
255
+ // Features é o caminho mais curto para o rate limit secundário.
256
+ const featureIssues = issues.filter(i => detectIssueType(i) === 'Feature');
257
+ const specStates = {};
258
+ for (const issue of featureIssues) {
259
+ const docs = featureDocPaths(root, issue, 'Feature');
260
+ const estados = await artifactStates({
261
+ token, owner, repo, root, base,
262
+ issueNumber: issue.number,
263
+ docs: [{ doc: 'spec', pathRel: docs.spec.rel }],
264
+ });
265
+ specStates[issue.number] = estados.spec;
266
+ }
267
+
268
+ const inventario = inspectMilestone({ issues, specStates });
269
+ const veredito = inspectPreflight({ tokenOk: true, modo, publicacao, inventario });
270
+ s?.stop(`Milestone "${milestone.title}": ${inventario.features.length} Feature(s).`);
271
+
272
+ Object.assign(saida, {
273
+ milestone: { title: milestone.title, number: milestone.number, base },
274
+ modo: { configured: configuredMode(config), variable: variable ?? null, status: modo.status, summary: modo.summary },
275
+ publicacao: { status: publicacao.status, notes: publicacao.notes },
276
+ inventario,
277
+ veredito,
278
+ });
279
+
280
+ if (json) {
281
+ console.log(JSON.stringify(saida, null, 2));
282
+ if (veredito.status === 'bloqueio') process.exitCode = 1;
283
+ return saida;
284
+ }
285
+
286
+ // ---------- Relatório ----------
287
+ const linhas = [];
288
+ linhas.push(`${chalk.bold('Repositório:')} ${owner}/${repo} (base: ${base})`);
289
+ linhas.push(`${chalk.bold('Modo:')} config=${configuredMode(config)} · variável=${variable ?? '<ausente>'}`);
290
+ linhas.push(` ${modo.summary}`);
291
+ linhas.push(`${chalk.bold('Publicação por PR:')} ${publicacao.status}`);
292
+ for (const nota of publicacao.notes) linhas.push(` ${nota}`);
293
+ p.note(linhas.join('\n'), 'Ambiente');
294
+
295
+ const inv = [];
296
+ for (const f of inventario.features) {
297
+ const marca = f.closed ? '× fechada' : (MARCA[f.spec] || f.spec);
298
+ const pr = f.pr ? chalk.dim(` PR #${f.pr.number}`) : '';
299
+ inv.push(` ${marca.padEnd(20)} #${f.number} ${f.title.slice(0, 60)}${pr}`);
300
+ }
301
+ if (inventario.gatilhosPendentes.length) {
302
+ inv.push('');
303
+ inv.push(chalk.yellow(' Labels de gatilho pendentes (remova antes de gerar):'));
304
+ for (const g of inventario.gatilhosPendentes) inv.push(` #${g.number}: ${g.labels.join(', ')}`);
305
+ }
306
+ p.note(inv.join('\n'), `Milestone "${milestone.title}" — ${inventario.gerar.length} a gerar`);
307
+
308
+ for (const a of veredito.avisos) p.log.warn(a);
309
+ for (const b of veredito.bloqueios) p.log.error(b);
310
+
311
+ if (veredito.status === 'bloqueio') {
312
+ p.outro(`${veredito.bloqueios.length} bloqueio(s). Resolva antes de gerar.`);
313
+ process.exitCode = 1;
314
+ return saida;
315
+ }
316
+ p.outro(
317
+ inventario.gerar.length
318
+ ? `Preflight ok. Confirme a lista com o usuário antes de gerar ${inventario.gerar.length} spec(s).`
319
+ : 'Preflight ok. Nada a gerar.'
320
+ );
321
+ return saida;
322
+ }
@@ -81,7 +81,7 @@ export function lockPath(root, key) {
81
81
  return path.join(gitCommonDir(root), 'spec-wave', `run-${key}.lock`);
82
82
  }
83
83
 
84
- function acquireLock(root, key) {
84
+ export function acquireLock(root, key) {
85
85
  const file = lockPath(root, key);
86
86
  mkdirSync(path.dirname(file), { recursive: true });
87
87
  const payload = JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() });
@@ -108,7 +108,7 @@ function acquireLock(root, key) {
108
108
  }
109
109
  }
110
110
 
111
- function releaseLock(file) {
111
+ export function releaseLock(file) {
112
112
  try {
113
113
  if (file) unlinkSync(file);
114
114
  } catch { /* já removido */ }
@@ -348,7 +348,8 @@ async function runForPr({ prNumber, dryRun, yes, only, json }) {
348
348
  process.exitCode = decision.blocked ? 2 : 0;
349
349
  return decision;
350
350
  }
351
- if (verdict.changesRequestedAfterApproval && !yes) {
351
+ // Mergeado, o pedido de mudanças pré-merge já foi decidido por quem mergeou.
352
+ if (verdict.changesRequestedAfterApproval && !pr.merged_at && !yes) {
352
353
  console.log(chalk.yellow('\n⛔ needs-confirmation: há pedido de mudanças além da aprovação. Confirme com `--yes`.'));
353
354
  process.exitCode = 2;
354
355
  return decision;
@@ -12,7 +12,7 @@ import {
12
12
  } from '../api/github-rest.mjs';
13
13
  import {
14
14
  resolveBranchName, composePrTitle, composePrBody, buildCommitMessage,
15
- decideConfigInPr, explainGitWriteError,
15
+ decideConfigInPr, decideSkillInPr, composeConfigCleanupHint, explainGitWriteError,
16
16
  } from '../lib/pr-branch.mjs';
17
17
  // MESMO readTemplate do init: resolve {{CLI_VERSION}} antes da comparação byte a
18
18
  // byte com o remoto. Se só o init resolvesse, todo update veria os workflows
@@ -20,10 +20,11 @@ import {
20
20
  import { readTemplate } from '../lib/templates.mjs';
21
21
  import {
22
22
  TARGETS, SKILL_SOURCE, CLI_VERSION, parseSkill, renderContent,
23
- mergeAgentsFile, resolveDest, isDetected, skillCopyReason, versionBanner,
23
+ mergeAgentsFile, mergeAgentsContent, resolveDest, isDetected, skillCopyReason, versionBanner,
24
24
  } from './install-skill.mjs';
25
25
  import { planPluginSkillFiles } from '../lib/plugin-skills.mjs';
26
26
  import { findConfigPath } from '../lib/project-root.mjs';
27
+ import { currentGitBranch } from '../lib/repo-links.mjs';
27
28
 
28
29
  // Arquivos do repo gerenciados pela CLI (comparados com o template empacotado).
29
30
  const REPO_FILES = [
@@ -68,6 +69,41 @@ function applySkill(job) {
68
69
  writeFileSync(job.dest.path, content, 'utf-8');
69
70
  }
70
71
 
72
+ /**
73
+ * Arquivos que uma atualização de skill grava, em caminhos RELATIVOS à raiz do
74
+ * repositório — a chave para perguntar à base se aquela cópia é versionada.
75
+ *
76
+ * Devolve [] quando o destino cai FORA da raiz, e é o que dispensa um caso
77
+ * especial para `--global`: `~/.claude/skills/...` e o `~/.gemini/AGENTS.md` do
78
+ * Antigravity jamais são conteúdo de repositório, e `path.relative` já diz isso.
79
+ *
80
+ * O formato `agents` (AGENTS.md) devolve `block` em vez de `content`: o conteúdo
81
+ * final depende do que existe na BASE — mesclar no arquivo local arrastaria para
82
+ * dentro do PR as edições não commitadas do desenvolvedor.
83
+ *
84
+ * @param {{dest: {path: string, format: string}, desired?: string}} job
85
+ * @param {string} repoRoot
86
+ * @returns {Array<{ relPath: string, content?: string, block?: string }>}
87
+ */
88
+ export function skillRepoTargets(job, repoRoot) {
89
+ const items = job.dest.format === 'skills-dir'
90
+ ? planPluginSkillFiles(job.dest.path, CLI_VERSION, versionBanner)
91
+ .map(f => ({ absPath: f.path, content: f.content }))
92
+ : [{
93
+ absPath: job.dest.path,
94
+ ...(job.dest.format === 'agents' ? { block: job.desired } : { content: job.desired }),
95
+ }];
96
+
97
+ const out = [];
98
+ for (const { absPath, ...rest } of items) {
99
+ const rel = path.relative(repoRoot, absPath);
100
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) continue;
101
+ // A API do GitHub fala POSIX; path.relative devolve no separador do SO.
102
+ out.push({ ...rest, relPath: rel.split(path.sep).join('/') });
103
+ }
104
+ return out;
105
+ }
106
+
71
107
  /**
72
108
  * Compara ALL_LABELS com as labels do repo (função PURA — testável).
73
109
  *
@@ -325,6 +361,70 @@ export async function update(options = {}) {
325
361
  }
326
362
  }
327
363
 
364
+ // 2d) Skill: a BASE versiona alguma destas cópias?
365
+ //
366
+ // Até aqui o update gravava a skill em disco e o corpo do PR AFIRMAVA ao
367
+ // revisor que ela "não faz parte do repositório" — premissa fixa. Em repos que
368
+ // versionam `.claude/skills/spec-wave/SKILL.md` (ou `.agents/skills/`, ou o
369
+ // AGENTS.md) isso era falso duas vezes: sobrava um commit manual a cada bump, e
370
+ // até ele o repositório seguia distribuindo a skill da versão anterior para
371
+ // quem clonasse. A checagem é a MESMA do .spec-wave.json — perguntar à base —,
372
+ // e a política também: só entra no PR o que o projeto já versiona.
373
+ let skillPrFiles = []; // [{ relPath, content, reason, agent }]
374
+ // agente → motivo da PRIMEIRA cópia incluída. É o motivo que o resumo mostra:
375
+ // dizer "versionada no repo" para uma cópia que entrou por `--skill-in-pr`
376
+ // seria repetir, em miniatura, a premissa que este código veio corrigir.
377
+ const skillPrAgents = new Map();
378
+ // `skillRepoTargets` já devolve vazio para o que mora fora da raiz, o que cobre
379
+ // o `--global` inteiro sem um caso especial: nada a perguntar, nada a gastar.
380
+ const skillCandidates = prMode
381
+ ? skillJobs
382
+ .map(job => ({ job, targets: skillRepoTargets(job, path.dirname(configPath)) }))
383
+ .filter(c => c.targets.length)
384
+ : [];
385
+ if (skillCandidates.length) {
386
+ const tk = await getToken();
387
+ const s = p.spinner();
388
+ s.start('Verificando se o repositório versiona a skill...');
389
+ try {
390
+ for (const { job, targets } of skillCandidates) {
391
+ // Em paralelo: o destino do Codex são 20+ arquivos, e um GET por vez
392
+ // transformaria a checagem na parte mais lenta do comando.
393
+ const remotes = await Promise.all(
394
+ targets.map(t => getFileContent(tk, owner, repo, t.relPath, base))
395
+ );
396
+ targets.forEach((t, i) => {
397
+ const remote = remotes[i];
398
+ const desired = t.block !== undefined
399
+ ? mergeAgentsContent(remote ?? '', t.block)
400
+ : t.content;
401
+ const d = decideSkillInPr({ remote, desired, force: options.skillInPr });
402
+ if (!d.included) return;
403
+ skillPrFiles.push({
404
+ relPath: t.relPath,
405
+ content: desired,
406
+ reason: remote == null ? 'ausente' : 'desatualizado',
407
+ agent: job.target.name,
408
+ });
409
+ if (!skillPrAgents.has(job.target.name)) skillPrAgents.set(job.target.name, d.reason);
410
+ });
411
+ }
412
+ s.stop(skillPrFiles.length
413
+ ? `Skill versionada no repositório: ${skillPrFiles.length} arquivo(s) vão no Pull Request.`
414
+ : 'A skill não é versionada neste repositório — segue apenas local.');
415
+ } catch (err) {
416
+ // Falhar aqui não pode custar o update inteiro: sem a resposta da base, a
417
+ // skill volta a ser só local — o comportamento anterior, agora explícito.
418
+ s.stop('');
419
+ p.log.warn(`Não foi possível checar a skill na base: ${err.message} — ela seguirá apenas local.`);
420
+ skillPrFiles = [];
421
+ skillPrAgents.clear();
422
+ }
423
+ }
424
+ const skillLocalAgents = skillJobs
425
+ .map(j => j.target.name)
426
+ .filter(n => !skillPrAgents.has(n));
427
+
328
428
  // Decisão preliminar sobre o config. O conteúdo regenerado ainda não existe;
329
429
  // `willRegenerate` cobre isso, porque o regenerado SEMPRE difere do remoto
330
430
  // (refreshedAt muda a cada execução). Na aplicação a decisão é recalculada com
@@ -355,21 +455,39 @@ export async function update(options = {}) {
355
455
  const lines = [];
356
456
  if (skillJobs.length) {
357
457
  lines.push(chalk.bold('Skill:'));
358
- for (const j of skillJobs) lines.push(` ${chalk.yellow('↻')} ${j.target.name} (${j.reason})\n ${chalk.dim(j.dest.path)}`);
458
+ for (const j of skillJobs) {
459
+ const destino = skillPrAgents.has(j.target.name)
460
+ ? chalk.dim(` → vai no PR: ${skillPrAgents.get(j.target.name)}`)
461
+ : '';
462
+ lines.push(` ${chalk.yellow('↻')} ${j.target.name} (${j.reason})${destino}\n ${chalk.dim(j.dest.path)}`);
463
+ }
359
464
  }
360
465
  if (configStale) {
361
466
  lines.push(chalk.bold('Config local:'));
362
467
  lines.push(` ${chalk.yellow('↻')} ${CONFIG_FILE} — ${configStale.reason}` +
363
468
  (configStale.canApply ? '' : chalk.dim(' (sem project.id — rode `init` sem --skip-project)')));
364
469
  }
470
+ // Cabeçalho ÚNICO no modo PR: workflows, skill e config viajam no mesmo commit,
471
+ // e três títulos para um commit só sugeriam três destinos diferentes.
472
+ let prHeaderShown = false;
473
+ const prHeader = () => {
474
+ if (prHeaderShown) return;
475
+ prHeaderShown = true;
476
+ lines.push(chalk.bold(`Pull Request (${branch} → ${base}):`));
477
+ };
365
478
  if (repoFiles.length) {
366
- lines.push(prMode
367
- ? chalk.bold(`Arquivos do repo → Pull Request (${branch} → ${base}):`)
368
- : chalk.bold('Arquivos do repo:'));
479
+ if (prMode) prHeader();
480
+ else lines.push(chalk.bold('Arquivos do repo:'));
369
481
  for (const f of repoFiles) lines.push(` ${chalk.yellow('↻')} ${f.repoPath} (${f.reason})`);
370
482
  }
483
+ if (skillPrFiles.length) {
484
+ prHeader();
485
+ for (const f of skillPrFiles) {
486
+ lines.push(` ${chalk.yellow('↻')} ${f.relPath} (${f.reason} — skill ${f.agent})`);
487
+ }
488
+ }
371
489
  if (prMode && configDecision.included) {
372
- if (!repoFiles.length) lines.push(chalk.bold(`Pull Request (${branch} → ${base}):`));
490
+ prHeader();
373
491
  lines.push(` ${chalk.yellow('↻')} ${CONFIG_FILE} (${configDecision.reason})`);
374
492
  }
375
493
  if (prMode && doConfig && !configDecision.included && configDecision.reason) {
@@ -388,7 +506,8 @@ export async function update(options = {}) {
388
506
  p.note(lines.join('\n'), `${total} item(ns) desatualizado(s)`);
389
507
 
390
508
  // --branch sem efeito: melhor dizer POR QUE do que criar uma branch inútil.
391
- const prHasPayload = prMode && (repoFiles.length > 0 || configDecision.included);
509
+ const prHasPayload = prMode
510
+ && (repoFiles.length > 0 || skillPrFiles.length > 0 || configDecision.included);
392
511
  if (prRequested && options.skipRepo) {
393
512
  p.log.warn(
394
513
  '--branch ignorado junto com --skip-repo: o Pull Request existe justamente para ' +
@@ -408,7 +527,7 @@ export async function update(options = {}) {
408
527
 
409
528
  if (options.dryRun) {
410
529
  if (prHasPayload) {
411
- const n = repoFiles.length + (configDecision.included ? 1 : 0);
530
+ const n = repoFiles.length + skillPrFiles.length + (configDecision.included ? 1 : 0);
412
531
  p.note(
413
532
  `Branch: ${branch}${branchExists ? ' (já existe — o commit seria empilhado nela)' : ' (seria criada)'}\n` +
414
533
  `Base: ${base}\n` +
@@ -516,6 +635,11 @@ export async function update(options = {}) {
516
635
  const tk = await getToken();
517
636
  const treeFiles = [
518
637
  ...repoFiles.map(f => ({ path: f.repoPath, reason: f.reason, content: f.local })),
638
+ ...skillPrFiles.map(f => ({
639
+ path: f.relPath,
640
+ reason: `${f.reason} — skill ${f.agent}`,
641
+ content: f.content,
642
+ })),
519
643
  ...(finalConfigDecision.included
520
644
  ? [{
521
645
  path: CONFIG_FILE,
@@ -562,7 +686,7 @@ export async function update(options = {}) {
562
686
  files: treeFiles,
563
687
  config: doConfig ? finalConfigDecision : null,
564
688
  labels: labelResult,
565
- skill: skillJobs.map(j => j.target.name),
689
+ skill: { inPr: [...skillPrAgents.keys()], local: skillLocalAgents },
566
690
  }),
567
691
  });
568
692
  if (!pr) {
@@ -605,9 +729,16 @@ export async function update(options = {}) {
605
729
  (prUrl ? ` Revise e faça o merge do Pull Request: ${prUrl}` : '') +
606
730
  (!prMode && repoFiles.length ? ' Arquivos do repo foram commitados no remoto.' : '') +
607
731
  (configPending ? ` COMMITE o ${CONFIG_FILE} — quem clona o repo (dev-agent, Actions) lê a versão commitada.` : '') +
732
+ // A dica de descarte depende da branch do CHECKOUT, não de premissa:
733
+ // `git checkout -- <arquivo>` restaura o que está no índice, e rodado de uma
734
+ // branch de trabalho reverte o arquivo para a versão ANTIGA dela — o oposto
735
+ // do que a mensagem promete.
608
736
  (finalConfigDecision.included
609
- ? ` O ${CONFIG_FILE} local ficou igual ao do PR — depois do merge, descarte a cópia ` +
610
- `local com \`git checkout -- ${CONFIG_FILE}\`.`
737
+ ? ` ${composeConfigCleanupHint({
738
+ file: CONFIG_FILE,
739
+ base,
740
+ current: currentGitBranch(path.dirname(configPath)),
741
+ })}`
611
742
  : '')
612
743
  );
613
744
  }
package/src/lib/board.mjs CHANGED
@@ -218,11 +218,27 @@ export async function advanceToStage(
218
218
  const current = await getItemSingleSelectValue(token, itemId, etapaField.id).catch(() => null);
219
219
  if (!shouldAdvanceStage(current, targetStage)) return false;
220
220
  const optionId = etapaField.options?.[targetStage];
221
- if (optionId) await setItemSingleSelect(token, project.id, itemId, etapaField.id, optionId);
221
+ // Opção que não resolve é board divergente (coluna renomeada, init de outra
222
+ // versão) — pular a escrita e devolver true fazia o chamador imprimir ✅
223
+ // sem nada ter sido escrito. Melhor falhar nomeando o que faltou.
224
+ if (!optionId) {
225
+ throw new Error(
226
+ `A Etapa "${targetStage}" não existe no board (opções: ` +
227
+ `${Object.keys(etapaField.options || {}).join(', ') || 'nenhuma'}). ` +
228
+ 'O board divergiu da config — rode `spec-wave refresh` ou renomeie a coluna de volta.'
229
+ );
230
+ }
231
+ await setItemSingleSelect(token, project.id, itemId, etapaField.id, optionId);
222
232
  }
223
233
  if (statusField?.id && targetStatus) {
224
234
  const optionId = statusField.options?.[targetStatus];
225
- if (optionId) await setItemSingleSelect(token, project.id, itemId, statusField.id, optionId);
235
+ if (!optionId) {
236
+ throw new Error(
237
+ `O Status "${targetStatus}" não existe no board (opções: ` +
238
+ `${Object.keys(statusField.options || {}).join(', ') || 'nenhuma'}).`
239
+ );
240
+ }
241
+ await setItemSingleSelect(token, project.id, itemId, statusField.id, optionId);
226
242
  }
227
243
  return true;
228
244
  }