@spec-wave/cli 0.15.0 → 0.16.1

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 (78) hide show
  1. package/README.md +1 -0
  2. package/bin/spec-wave.mjs +44 -5
  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-graphql.mjs +23 -1
  13. package/src/api/github-rest.mjs +8 -0
  14. package/src/commands/bug.mjs +8 -0
  15. package/src/commands/code-review.mjs +45 -4
  16. package/src/commands/decompose.mjs +22 -72
  17. package/src/commands/dev-agent.mjs +3 -3
  18. package/src/commands/doctor.mjs +77 -6
  19. package/src/commands/generate-bug.mjs +195 -0
  20. package/src/commands/generate-plan.mjs +19 -44
  21. package/src/commands/generate-spec.mjs +18 -46
  22. package/src/commands/implement.mjs +105 -2
  23. package/src/commands/init.mjs +3 -3
  24. package/src/commands/install-skill.mjs +72 -16
  25. package/src/commands/issue.mjs +9 -7
  26. package/src/commands/move.mjs +11 -1
  27. package/src/commands/qa.mjs +23 -2
  28. package/src/commands/refresh.mjs +171 -5
  29. package/src/commands/triage.mjs +174 -0
  30. package/src/commands/update.mjs +16 -3
  31. package/src/commands/validate.mjs +82 -10
  32. package/src/config.mjs +159 -1
  33. package/src/lib/bug-context.mjs +160 -0
  34. package/src/lib/bug-doc.mjs +51 -0
  35. package/src/lib/bug-triage.mjs +81 -0
  36. package/src/lib/claude.mjs +71 -254
  37. package/src/lib/critique.mjs +43 -30
  38. package/src/lib/flow-run.mjs +145 -0
  39. package/src/lib/implement-board.mjs +12 -1
  40. package/src/lib/plugin-skills.mjs +122 -0
  41. package/src/lib/project-root.mjs +9 -2
  42. package/src/lib/prompt-loader.mjs +257 -0
  43. package/src/lib/skill-file.mjs +35 -0
  44. package/src/plugin/.claude-plugin/plugin.json +20 -0
  45. package/src/plugin/README.md +73 -0
  46. package/src/plugin/skills/bug/SKILL.md +60 -0
  47. package/src/plugin/skills/bug/model-prompt.critique.md +48 -0
  48. package/src/plugin/skills/bug/model-prompt.md +74 -0
  49. package/src/plugin/skills/decompose/SKILL.md +117 -0
  50. package/src/plugin/skills/decompose/model-prompt.critique.md +46 -0
  51. package/src/plugin/skills/decompose/model-prompt.feature.md +69 -0
  52. package/src/plugin/skills/decompose/model-prompt.rfc.md +52 -0
  53. package/src/plugin/skills/doctor/SKILL.md +51 -0
  54. package/src/plugin/skills/fix-pr/SKILL.md +130 -0
  55. package/src/plugin/skills/implement/SKILL.md +102 -0
  56. package/src/plugin/skills/info/SKILL.md +40 -0
  57. package/src/plugin/skills/issue/SKILL.md +63 -0
  58. package/src/plugin/skills/move/SKILL.md +52 -0
  59. package/src/plugin/skills/order/SKILL.md +36 -0
  60. package/src/plugin/skills/plan/SKILL.md +58 -0
  61. package/src/plugin/skills/plan/model-prompt.critique.md +44 -0
  62. package/src/plugin/skills/plan/model-prompt.md +59 -0
  63. package/src/plugin/skills/plan/reference/tech-context.md +56 -0
  64. package/src/plugin/skills/ready/SKILL.md +44 -0
  65. package/src/plugin/skills/rfc/SKILL.md +47 -0
  66. package/src/plugin/skills/setup/SKILL.md +67 -0
  67. package/src/plugin/skills/spec/SKILL.md +55 -0
  68. package/src/plugin/skills/spec/model-prompt.md +61 -0
  69. package/src/plugin/skills/story/SKILL.md +49 -0
  70. package/src/plugin/skills/task/SKILL.md +41 -0
  71. package/src/plugin/skills/triage/SKILL.md +52 -0
  72. package/src/plugin/skills/uninstall/SKILL.md +43 -0
  73. package/src/plugin/skills/update/SKILL.md +51 -0
  74. package/src/plugin/skills/workflow/SKILL.md +158 -0
  75. package/src/templates/skill/SKILL.md +54 -4
  76. package/src/templates/workflows/generate-bug.yml +36 -0
  77. package/src/templates/workflows/validate.yml +2 -1
  78. package/src/ui/wizard.mjs +5 -2
@@ -4,20 +4,174 @@ import { readFileSync, writeFileSync } from 'node:fs';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import path from 'node:path';
6
6
  import { resolveToken } from '../api/auth.mjs';
7
- import { CONFIG_FILE } from '../config.mjs';
7
+ import { CONFIG_FILE, STATUS_OPTIONS } from '../config.mjs';
8
8
  import { findConfigPath } from '../lib/project-root.mjs';
9
- import { getProjectSnapshot } from '../api/github-graphql.mjs';
9
+ import { getProjectSnapshot, updateStatusField } from '../api/github-graphql.mjs';
10
10
 
11
11
  const __dir = path.dirname(fileURLToPath(import.meta.url));
12
12
  const pkg = JSON.parse(readFileSync(path.join(__dir, '..', '..', 'package.json'), 'utf-8'));
13
13
 
14
+ /**
15
+ * Plano de sincronização das opções do campo "Etapa" (função PURA).
16
+ *
17
+ * A operação é DELIBERADAMENTE aditiva: `ordered` contém tudo que já existe no
18
+ * board mais as etapas canônicas que faltam, cada uma na posição canônica. Nada
19
+ * é removido — nem coluna inventada, nem etapa descontinuada.
20
+ *
21
+ * ⚠️ E cada opção existente leva seu **id** junto. O contrato da API é o que
22
+ * torna isso obrigatório: `updateProjectV2Field` SUBSTITUI o conjunto de
23
+ * opções, e o que liga um item do board a uma opção é o ID — não o nome. Uma
24
+ * opção reenviada sem id é RECRIADA com id novo, e todo item que estava nela
25
+ * fica sem Etapa, em silêncio.
26
+ *
27
+ * A versão anterior preservava só o nome. No smoke test do RFC-004 isso apagou
28
+ * a Etapa de uma Feature que estava em 🎯 Priorizado, enquanto o comando
29
+ * relatava "nenhuma é removida" — verdade sobre os nomes, mentira sobre o
30
+ * board.
31
+ *
32
+ * @param {Record<string, string>} current opções hoje no board: nome → id
33
+ * @param {Array<{name: string, color: string}>} canonical STATUS_OPTIONS
34
+ * @returns {{ missing: string[], preserved: string[],
35
+ * ordered: Array<{name: string, color: string, id?: string}> }}
36
+ */
37
+ export function planStageSync(current, canonical) {
38
+ const atual = current || {};
39
+ const idDe = (name) => atual[name];
40
+ const canonicalNames = new Set(canonical.map(o => o.name));
41
+
42
+ const missing = canonical.filter(o => !idDe(o.name)).map(o => o.name);
43
+ // Colunas que o board tem e o fluxo canônico não conhece (inventadas ou
44
+ // descontinuadas). Vão para o fim, preservando a ordem relativa que tinham.
45
+ const preserved = Object.keys(atual).filter(name => !canonicalNames.has(name));
46
+
47
+ const ordered = [
48
+ ...canonical.map(o => {
49
+ const id = idDe(o.name);
50
+ return id ? { name: o.name, color: o.color, id } : { name: o.name, color: o.color };
51
+ }),
52
+ ...preserved.map(name => ({ name, color: 'GRAY', id: atual[name] })),
53
+ ];
54
+ return { missing, preserved, ordered };
55
+ }
56
+
57
+ /**
58
+ * Aplica o plano de `planStageSync` no campo "Etapa" do Project.
59
+ *
60
+ * Escreve só quando há etapa canônica faltando, e verifica o resultado relendo
61
+ * o Project: se alguma opção que existia antes sumiu, grita — é o único jeito
62
+ * de o usuário descobrir a tempo que itens perderam a Etapa.
63
+ *
64
+ * @returns {Promise<boolean>} true se o comando deve continuar
65
+ */
66
+ async function syncStages(token, snapshot, options) {
67
+ const etapa = snapshot.fields?.['Etapa'];
68
+ if (!etapa?.id) {
69
+ p.log.error('Campo "Etapa" não encontrado no Project — nada a sincronizar.');
70
+ process.exitCode = 1;
71
+ return false;
72
+ }
73
+
74
+ // Mapa nome → id: é o id que preserva o vínculo dos itens com a opção.
75
+ const current = etapa.options || {};
76
+ const nomesAntes = Object.keys(current);
77
+ const { missing, preserved, ordered } = planStageSync(current, STATUS_OPTIONS);
78
+
79
+ if (missing.length === 0) {
80
+ p.log.success(`Campo "Etapa" já tem as ${STATUS_OPTIONS.length} etapas canônicas.`);
81
+ return true;
82
+ }
83
+
84
+ p.note(
85
+ `${chalk.green('+')} acrescentar: ${missing.join(', ')}\n` +
86
+ (preserved.length
87
+ ? `${chalk.dim('=')} preservar (fora do fluxo canônico): ${preserved.join(', ')}\n`
88
+ : '') +
89
+ `${chalk.dim('=')} preservar (canônicas já presentes): ${nomesAntes.length - preserved.length}\n\n` +
90
+ chalk.dim(`Resultado: ${ordered.length} opções. Nenhuma é removida.`),
91
+ 'Plano para o campo "Etapa"'
92
+ );
93
+
94
+ if (options.dryRun) {
95
+ p.outro('Dry-run: nada foi enviado ao GitHub.');
96
+ return false;
97
+ }
98
+
99
+ if (!options.yes) {
100
+ const go = await p.confirm({
101
+ message: 'Reescrever as opções do campo "Etapa" com esse conjunto?',
102
+ initialValue: false,
103
+ });
104
+ if (p.isCancel(go) || !go) {
105
+ p.outro('Cancelado — nada foi alterado.');
106
+ return false;
107
+ }
108
+ }
109
+
110
+ const spinner = p.spinner();
111
+ spinner.start('Atualizando o campo "Etapa"...');
112
+ try {
113
+ await updateStatusField(token, etapa.id, ordered);
114
+ } catch (err) {
115
+ spinner.stop('');
116
+ p.log.error(`Falha ao atualizar o campo "Etapa": ${err.message}`);
117
+ process.exitCode = 1;
118
+ return false;
119
+ }
120
+
121
+ // Verificação pós-escrita: releitura, não confiança na mutação.
122
+ let after;
123
+ try {
124
+ after = await getProjectSnapshot(token, snapshot.id);
125
+ } catch (err) {
126
+ spinner.stop('');
127
+ p.log.warn(`Campo atualizado, mas a verificação falhou: ${err.message}`);
128
+ return true;
129
+ }
130
+ const depois = after?.fields?.['Etapa']?.options || {};
131
+ const now = Object.keys(depois);
132
+ const lost = nomesAntes.filter(name => !now.includes(name));
133
+ // A verificação que importa: o ID de cada opção preexistente tem que ser o
134
+ // MESMO. Conferir só o nome era o que deixava passar o pior desfecho — as 12
135
+ // opções presentes, todos os ids trocados, e o board inteiro sem Etapa.
136
+ const recriadas = nomesAntes.filter(name => depois[name] && depois[name] !== current[name]);
137
+ const stillMissing = STATUS_OPTIONS.map(o => o.name).filter(name => !now.includes(name));
138
+ spinner.stop(`Campo "Etapa" com ${now.length} opções.`);
139
+
140
+ if (lost.length > 0) {
141
+ p.log.error(
142
+ `Opções que existiam SUMIRAM: ${lost.join(', ')}. Os itens que estavam nelas ficaram ` +
143
+ 'sem Etapa — recrie a coluna com o mesmo nome e reposicione os itens.'
144
+ );
145
+ process.exitCode = 1;
146
+ return false;
147
+ }
148
+ if (recriadas.length > 0) {
149
+ p.log.error(
150
+ `Opções RECRIADAS com id novo: ${recriadas.join(', ')}. Os itens que estavam nelas ` +
151
+ 'perderam a Etapa — reposicione-os no board. Isto é um bug do comando, não do seu ' +
152
+ 'Project: reporte com a saída acima.'
153
+ );
154
+ process.exitCode = 1;
155
+ return false;
156
+ }
157
+ if (stillMissing.length > 0) {
158
+ p.log.warn(`Etapas canônicas ainda ausentes: ${stillMissing.join(', ')}.`);
159
+ return true;
160
+ }
161
+ p.log.success(`Etapas acrescentadas: ${missing.join(', ')}.`);
162
+ return true;
163
+ }
164
+
14
165
  // Re-consulta o GitHub Project e reescreve o .spec-wave.json local com os dados
15
166
  // atuais (id/number/url/title do Project, IDs do campo Etapa e das opções, e a
16
167
  // versão da CLI). Útil para repositórios inicializados antes do enriquecimento,
17
168
  // ou quando o Project foi renomeado/teve campos alterados.
18
169
  export async function refresh(options = {}) {
19
- if (!options.config) {
20
- p.log.error('Nada a fazer. Use `spec-wave refresh --config` para atualizar o .spec-wave.json.');
170
+ if (!options.config && !options.stages) {
171
+ p.log.error(
172
+ 'Nada a fazer. Use `spec-wave refresh --config` para atualizar o .spec-wave.json, ' +
173
+ 'ou `--stages` para acrescentar as colunas canônicas que faltam no campo "Etapa".'
174
+ );
21
175
  process.exitCode = 1;
22
176
  return;
23
177
  }
@@ -57,7 +211,7 @@ export async function refresh(options = {}) {
57
211
  return;
58
212
  }
59
213
 
60
- p.intro(chalk.bold('spec-wave refresh --config'));
214
+ p.intro(chalk.bold(`spec-wave refresh${options.stages ? ' --stages' : ' --config'}`));
61
215
 
62
216
  const spinner = p.spinner();
63
217
  spinner.start('Consultando o GitHub Project...');
@@ -78,6 +232,18 @@ export async function refresh(options = {}) {
78
232
  }
79
233
  spinner.stop('Project consultado.');
80
234
 
235
+ if (options.stages) {
236
+ const ok = await syncStages(token, snapshot, options);
237
+ if (!ok) return;
238
+ // Depois de mexer no board, o .spec-wave.json guarda ids de opção velhos —
239
+ // relê o Project para que o resto do comando grave o estado novo.
240
+ snapshot = await getProjectSnapshot(token, projectId);
241
+ if (!options.config) {
242
+ p.outro('Campo "Etapa" sincronizado. Rode `spec-wave refresh --config` para atualizar o .spec-wave.json.');
243
+ return;
244
+ }
245
+ }
246
+
81
247
  // Remove campos legados (versões anteriores gravavam etapaFieldId/stageOptions soltos).
82
248
  const { etapaFieldId: _e, stageOptions: _s, ...projectRest } = config.project;
83
249
  const updated = {
@@ -0,0 +1,174 @@
1
+ // Triagem de Bug pela CLI (RFC-004 §4.1) — o mesmo desfecho que a tela do PM
2
+ // oferece, para quem trabalha no terminal ou automatiza.
3
+ //
4
+ // Três saídas, e nenhuma delas é "editar": aceitar (→ ✅ Ready), rejeitar
5
+ // (fecha) e duplicar (fecha, apontando a original). Corrigir o relato é
6
+ // conversa na issue.
7
+ //
8
+ // Rejeitar e duplicar NÃO mexem na Etapa: ela nunca retrocede, e um bug
9
+ // rejeitado não avançou para lugar nenhum — quem o tira das filas é o estado
10
+ // `closed` da issue.
11
+ import * as p from '@clack/prompts';
12
+ import chalk from 'chalk';
13
+ import { resolveToken } from '../api/auth.mjs';
14
+ import {
15
+ getIssue, addLabel, removeLabel, commentOnIssue, setIssueState,
16
+ } from '../api/github-rest.mjs';
17
+ import { detectIssueType } from '../lib/issue-type.mjs';
18
+ import { loadProjectConfig, resolveField, advanceToStage } from '../lib/board.mjs';
19
+ import { loadConfig } from '../lib/project-root.mjs';
20
+ import { resolveTriageAction, canAcceptBug } from '../lib/bug-triage.mjs';
21
+ import {
22
+ CONFIG_FILE, STAGE_READY, PROGRESS_TODO, PRIORITY_LABELS,
23
+ LABEL_TRIAGED, LABEL_DUPLICATE, LABEL_WONT_FIX, labelNames,
24
+ } from '../config.mjs';
25
+
26
+ const PRIORITIES = PRIORITY_LABELS.map(l => l.name);
27
+
28
+ export async function triage({ action: actionArg, issue: issueArg, reason, of, severity }) {
29
+ const { action, error: actionError } = resolveTriageAction(actionArg);
30
+ if (actionError) {
31
+ p.log.error(actionError);
32
+ process.exitCode = 1;
33
+ return;
34
+ }
35
+
36
+ const issueNumber = parseInt(String(issueArg).replace('#', ''), 10);
37
+ if (!Number.isInteger(issueNumber) || issueNumber <= 0) {
38
+ p.log.error(`Issue inválida: "${issueArg}". Use o número da issue, ex.: 42 ou #42.`);
39
+ process.exitCode = 1;
40
+ return;
41
+ }
42
+ if (severity && !PRIORITIES.includes(severity)) {
43
+ p.log.error(`Severidade inválida: ${severity}. Use uma de: ${PRIORITIES.join(', ')}.`);
44
+ process.exitCode = 1;
45
+ return;
46
+ }
47
+
48
+ const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
49
+ const { config, root } = loadConfig();
50
+ const owner = envOwner || config?.owner;
51
+ const repo = envRepo || config?.repo;
52
+ if (!owner || !repo) {
53
+ p.log.error(
54
+ 'Não foi possível determinar owner/repo.\n' +
55
+ `Rode dentro de um repositório com ${CONFIG_FILE} ou defina GITHUB_REPOSITORY=owner/repo.`
56
+ );
57
+ process.exitCode = 1;
58
+ return;
59
+ }
60
+
61
+ let token;
62
+ try {
63
+ token = await resolveToken();
64
+ } catch (err) {
65
+ p.log.error(err.message);
66
+ process.exitCode = 1;
67
+ return;
68
+ }
69
+
70
+ p.intro(chalk.bold(`spec-wave triage ${action} #${issueNumber}`));
71
+
72
+ let issue;
73
+ try {
74
+ issue = await getIssue(token, owner, repo, issueNumber);
75
+ } catch (err) {
76
+ p.log.error(`Não foi possível ler a issue #${issueNumber}: ${err.message}`);
77
+ process.exitCode = 1;
78
+ return;
79
+ }
80
+
81
+ if (detectIssueType(issue) !== 'Bug') {
82
+ p.log.error(`#${issueNumber} não é um Bug — a triagem só se aplica a defeitos.`);
83
+ process.exitCode = 1;
84
+ return;
85
+ }
86
+
87
+ const labels = labelNames(issue);
88
+ const severidadeAtual = severity || labels.find(n => PRIORITIES.includes(n)) || null;
89
+
90
+ if (action === 'accept') {
91
+ const { ok, error } = canAcceptBug({ severity: severidadeAtual, labels });
92
+ if (!ok) {
93
+ p.log.error(error);
94
+ process.exitCode = 1;
95
+ return;
96
+ }
97
+
98
+ // Reclassificação: troca a label em vez de acumular. Duas prioridades na
99
+ // mesma issue tornam a ordem da fila indefinida.
100
+ if (severity) {
101
+ for (const outra of PRIORITIES) {
102
+ if (outra !== severity) await removeLabel(token, owner, repo, issueNumber, outra).catch(() => {});
103
+ }
104
+ await addLabel(token, owner, repo, issueNumber, severity);
105
+ p.log.info(`Severidade → ${severity}.`);
106
+ }
107
+
108
+ await addLabel(token, owner, repo, issueNumber, LABEL_TRIAGED);
109
+ await commentOnIssue(token, owner, repo, issueNumber,
110
+ '**Triado:** aceito para a fila técnica.').catch(() => {});
111
+ await moveToReady({ token, issueNumber, issue, root });
112
+
113
+ p.outro(`${chalk.green('✓')} Bug #${issueNumber} aceito → ${STAGE_READY}.`);
114
+ return;
115
+ }
116
+
117
+ if (action === 'reject') {
118
+ if (!reason || !reason.trim()) {
119
+ p.log.error('Rejeitar exige --reason "<motivo>".');
120
+ process.exitCode = 1;
121
+ return;
122
+ }
123
+ await addLabel(token, owner, repo, issueNumber, LABEL_WONT_FIX);
124
+ await addLabel(token, owner, repo, issueNumber, LABEL_TRIAGED);
125
+ await commentOnIssue(token, owner, repo, issueNumber,
126
+ `**Rejeitado na triagem:** ${reason.trim()}`).catch(() => {});
127
+ await setIssueState(token, owner, repo, issueNumber, 'closed');
128
+ p.outro(`${chalk.green('✓')} Bug #${issueNumber} rejeitado e fechado.`);
129
+ return;
130
+ }
131
+
132
+ // duplicate
133
+ const original = parseInt(String(of || '').replace('#', ''), 10);
134
+ if (!Number.isInteger(original) || original <= 0) {
135
+ p.log.error('Marcar como duplicata exige --of <número da issue original>.');
136
+ process.exitCode = 1;
137
+ return;
138
+ }
139
+ if (original === issueNumber) {
140
+ p.log.error('Uma issue não pode ser duplicata de si mesma.');
141
+ process.exitCode = 1;
142
+ return;
143
+ }
144
+
145
+ await addLabel(token, owner, repo, issueNumber, LABEL_DUPLICATE);
146
+ await addLabel(token, owner, repo, issueNumber, LABEL_TRIAGED);
147
+ await commentOnIssue(token, owner, repo, issueNumber, `**Duplicata de #${original}.**`).catch(() => {});
148
+ // Comentar também na original: sem isso, quem acompanha #original não fica
149
+ // sabendo que há outro relato do mesmo defeito — e o contexto extra está lá.
150
+ await commentOnIssue(token, owner, repo, original,
151
+ `#${issueNumber} foi marcada como duplicata desta issue.`).catch(() => {});
152
+ await setIssueState(token, owner, repo, issueNumber, 'closed');
153
+
154
+ p.outro(`${chalk.green('✓')} Bug #${issueNumber} marcado como duplicata de #${original} e fechado.`);
155
+ }
156
+
157
+ async function moveToReady({ token, issueNumber, issue, root }) {
158
+ const { project, error } = loadProjectConfig({ cwd: root || process.cwd() });
159
+ if (error) {
160
+ p.log.warn(`${error} — board não atualizado.`);
161
+ return;
162
+ }
163
+ try {
164
+ const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
165
+ const statusField = await resolveField(token, project, 'Status').catch(() => null);
166
+ const moved = await advanceToStage(
167
+ token, project, etapaField, statusField, issue.node_id, STAGE_READY, PROGRESS_TODO);
168
+ if (!moved) {
169
+ p.log.info(`#${issueNumber} já está em ${STAGE_READY} ou etapa posterior — mantido.`);
170
+ }
171
+ } catch (err) {
172
+ p.log.warn(`Falha ao mover no board: ${err.message}`);
173
+ }
174
+ }
@@ -20,8 +20,9 @@ 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,
23
+ mergeAgentsFile, resolveDest, isDetected, skillCopyReason, versionBanner,
24
24
  } from './install-skill.mjs';
25
+ import { planPluginSkillFiles } from '../lib/plugin-skills.mjs';
25
26
  import { findConfigPath } from '../lib/project-root.mjs';
26
27
 
27
28
  // Arquivos do repo gerenciados pela CLI (comparados com o template empacotado).
@@ -39,15 +40,27 @@ function detectSkill(parsed, baseDir, isGlobal) {
39
40
  const dest = resolveDest(target, baseDir, isGlobal);
40
41
  if (!dest) continue;
41
42
  const reason = skillCopyReason(dest, parsed);
42
- if (reason) {
43
- 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;
44
49
  }
50
+ jobs.push({ target, dest, desired: renderContent(dest.format, parsed, CLI_VERSION), reason });
45
51
  }
46
52
  return jobs;
47
53
  }
48
54
 
49
55
  // Aplica a atualização de uma skill (grava o arquivo / faz merge no AGENTS.md).
50
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
+ }
51
64
  const content = job.dest.format === 'agents'
52
65
  ? mergeAgentsFile(job.dest.path, job.desired)
53
66
  : job.desired;
@@ -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).