@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
@@ -20,7 +20,7 @@ import { loadProjectConfig, resolveField, advanceToStage, resolveStageName } fro
20
20
  import { loadConfig } from '../lib/project-root.mjs';
21
21
  import {
22
22
  CONFIG_FILE, PROGRESS_TODO, PROGRESS_IN_PROGRESS, PROGRESS_DONE,
23
- isManualStageType, MANUAL_STAGE_TYPES,
23
+ isManualStageType, MANUAL_STAGE_TYPES, isStageInTrack, STAGE_TRACKS,
24
24
  } from '../config.mjs';
25
25
 
26
26
  const PROGRESS_VALUES = [PROGRESS_TODO, PROGRESS_IN_PROGRESS, PROGRESS_DONE];
@@ -116,6 +116,16 @@ export async function move({ issue: issueArg, stage: stageArg, status: statusArg
116
116
  return;
117
117
  }
118
118
 
119
+ // Aviso, não bloqueio: a trilha por tipo é convenção (RFC-004 §7.1); a regra
120
+ // dura continua sendo "a Etapa só avança". Um caso legítimo fora da trilha
121
+ // (um Bug que o time decidiu homologar) não deve exigir escape hatch.
122
+ if (type && !isStageInTrack(type, stage)) {
123
+ p.log.warn(
124
+ `${type} normalmente não passa por ${stage} — a trilha do tipo é: ` +
125
+ `${STAGE_TRACKS[type].join(' → ')}.`
126
+ );
127
+ }
128
+
119
129
  const { project, error: projectError } = loadProjectConfig({ cwd: root || process.cwd() });
120
130
  if (projectError) {
121
131
  p.log.error(`${projectError} — board não atualizado. Rode \`spec-wave init\` (ou \`spec-wave refresh --config\`).`);
@@ -78,6 +78,27 @@ export async function qa({ prNumber }) {
78
78
  const updated = [];
79
79
 
80
80
  for (const num of issueNums) {
81
+ // Bug é unidade própria: vai para 🧪 QA sozinho, sem arrastar a Feature-pai
82
+ // (que pode ter Stories ainda em desenvolvimento).
83
+ const issue = await getIssue(token, owner, repo, num).catch(() => null);
84
+ if (issue && detectIssueType(issue) === 'Bug') {
85
+ if (seen.has(issue.number)) continue;
86
+ seen.add(issue.number);
87
+ try {
88
+ const moved = await advanceToStage(
89
+ projectToken, project, etapaField, statusField, issue.node_id, QA_STAGE, TODO_STATUS);
90
+ if (moved) {
91
+ updated.push(`#${issue.number} ${issue.title} (bug)`);
92
+ console.log(`Bug #${issue.number} → "${QA_STAGE}" / Status "${TODO_STATUS}".`);
93
+ } else {
94
+ console.log(`Bug #${issue.number} já está em "${QA_STAGE}" ou etapa posterior — mantido.`);
95
+ }
96
+ } catch (err) {
97
+ console.warn(`Falha ao atualizar Bug #${issue.number}: ${err.message}`);
98
+ }
99
+ continue;
100
+ }
101
+
81
102
  const feature = await resolveFeatureIssue(token, owner, repo, num);
82
103
  if (!feature || seen.has(feature.number)) continue;
83
104
  seen.add(feature.number);
@@ -99,10 +120,10 @@ export async function qa({ prNumber }) {
99
120
  await commentOnIssue(
100
121
  token, owner, repo, parseInt(prNumber, 10),
101
122
  `🧪 **PR aprovado — QA iniciado**\n\n` +
102
- `Feature(s) movida(s) para **${QA_STAGE}**:\n\n` +
123
+ `Item(ns) movido(s) para **${QA_STAGE}**:\n\n` +
103
124
  updated.map(f => `- ${f}`).join('\n')
104
125
  ).catch(() => {});
105
126
  }
106
127
 
107
- console.log(`qa: ${updated.length} feature(s) atualizada(s).`);
128
+ console.log(`qa: ${updated.length} item(ns) atualizado(s).`);
108
129
  }
@@ -4,20 +4,148 @@ 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
+ * O motivo é o contrato da API: `updateProjectV2Field` SUBSTITUI o conjunto de
22
+ * opções do single-select, e a preservação do valor de cada item do board
23
+ * depende de a opção continuar existindo com o mesmo nome. Uma opção que some
24
+ * leva junto a Etapa de todo item que estava nela — silenciosamente. Remover
25
+ * coluna continua sendo trabalho manual, feito com os itens já esvaziados.
26
+ *
27
+ * @param {string[]} current nomes das opções hoje no board, na ordem atual
28
+ * @param {Array<{name: string, color: string}>} canonical STATUS_OPTIONS
29
+ * @returns {{ missing: string[], preserved: string[], ordered: Array<{name: string, color: string}> }}
30
+ */
31
+ export function planStageSync(current, canonical) {
32
+ const have = new Set(current || []);
33
+ const canonicalNames = new Set(canonical.map(o => o.name));
34
+
35
+ const missing = canonical.filter(o => !have.has(o.name)).map(o => o.name);
36
+ // Colunas que o board tem e o fluxo canônico não conhece (inventadas ou
37
+ // descontinuadas). Vão para o fim, preservando a ordem relativa que tinham.
38
+ const preserved = (current || []).filter(name => !canonicalNames.has(name));
39
+
40
+ const ordered = [
41
+ ...canonical.map(o => ({ name: o.name, color: o.color })),
42
+ ...preserved.map(name => ({ name, color: 'GRAY' })),
43
+ ];
44
+ return { missing, preserved, ordered };
45
+ }
46
+
47
+ /**
48
+ * Aplica o plano de `planStageSync` no campo "Etapa" do Project.
49
+ *
50
+ * Escreve só quando há etapa canônica faltando, e verifica o resultado relendo
51
+ * o Project: se alguma opção que existia antes sumiu, grita — é o único jeito
52
+ * de o usuário descobrir a tempo que itens perderam a Etapa.
53
+ *
54
+ * @returns {Promise<boolean>} true se o comando deve continuar
55
+ */
56
+ async function syncStages(token, snapshot, options) {
57
+ const etapa = snapshot.fields?.['Etapa'];
58
+ if (!etapa?.id) {
59
+ p.log.error('Campo "Etapa" não encontrado no Project — nada a sincronizar.');
60
+ process.exitCode = 1;
61
+ return false;
62
+ }
63
+
64
+ const current = Object.keys(etapa.options || {});
65
+ const { missing, preserved, ordered } = planStageSync(current, STATUS_OPTIONS);
66
+
67
+ if (missing.length === 0) {
68
+ p.log.success(`Campo "Etapa" já tem as ${STATUS_OPTIONS.length} etapas canônicas.`);
69
+ return true;
70
+ }
71
+
72
+ p.note(
73
+ `${chalk.green('+')} acrescentar: ${missing.join(', ')}\n` +
74
+ (preserved.length
75
+ ? `${chalk.dim('=')} preservar (fora do fluxo canônico): ${preserved.join(', ')}\n`
76
+ : '') +
77
+ `${chalk.dim('=')} preservar (canônicas já presentes): ${current.length - preserved.length}\n\n` +
78
+ chalk.dim(`Resultado: ${ordered.length} opções. Nenhuma é removida.`),
79
+ 'Plano para o campo "Etapa"'
80
+ );
81
+
82
+ if (options.dryRun) {
83
+ p.outro('Dry-run: nada foi enviado ao GitHub.');
84
+ return false;
85
+ }
86
+
87
+ if (!options.yes) {
88
+ const go = await p.confirm({
89
+ message: 'Reescrever as opções do campo "Etapa" com esse conjunto?',
90
+ initialValue: false,
91
+ });
92
+ if (p.isCancel(go) || !go) {
93
+ p.outro('Cancelado — nada foi alterado.');
94
+ return false;
95
+ }
96
+ }
97
+
98
+ const spinner = p.spinner();
99
+ spinner.start('Atualizando o campo "Etapa"...');
100
+ try {
101
+ await updateStatusField(token, etapa.id, ordered);
102
+ } catch (err) {
103
+ spinner.stop('');
104
+ p.log.error(`Falha ao atualizar o campo "Etapa": ${err.message}`);
105
+ process.exitCode = 1;
106
+ return false;
107
+ }
108
+
109
+ // Verificação pós-escrita: releitura, não confiança na mutação.
110
+ let after;
111
+ try {
112
+ after = await getProjectSnapshot(token, snapshot.id);
113
+ } catch (err) {
114
+ spinner.stop('');
115
+ p.log.warn(`Campo atualizado, mas a verificação falhou: ${err.message}`);
116
+ return true;
117
+ }
118
+ const now = Object.keys(after?.fields?.['Etapa']?.options || {});
119
+ const lost = current.filter(name => !now.includes(name));
120
+ const stillMissing = STATUS_OPTIONS.map(o => o.name).filter(name => !now.includes(name));
121
+ spinner.stop(`Campo "Etapa" com ${now.length} opções.`);
122
+
123
+ if (lost.length > 0) {
124
+ p.log.error(
125
+ `Opções que existiam SUMIRAM: ${lost.join(', ')}. Os itens que estavam nelas ficaram ` +
126
+ 'sem Etapa — recrie a coluna com o mesmo nome e reposicione os itens.'
127
+ );
128
+ process.exitCode = 1;
129
+ return false;
130
+ }
131
+ if (stillMissing.length > 0) {
132
+ p.log.warn(`Etapas canônicas ainda ausentes: ${stillMissing.join(', ')}.`);
133
+ return true;
134
+ }
135
+ p.log.success(`Etapas acrescentadas: ${missing.join(', ')}.`);
136
+ return true;
137
+ }
138
+
14
139
  // Re-consulta o GitHub Project e reescreve o .spec-wave.json local com os dados
15
140
  // atuais (id/number/url/title do Project, IDs do campo Etapa e das opções, e a
16
141
  // versão da CLI). Útil para repositórios inicializados antes do enriquecimento,
17
142
  // ou quando o Project foi renomeado/teve campos alterados.
18
143
  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.');
144
+ if (!options.config && !options.stages) {
145
+ p.log.error(
146
+ 'Nada a fazer. Use `spec-wave refresh --config` para atualizar o .spec-wave.json, ' +
147
+ 'ou `--stages` para acrescentar as colunas canônicas que faltam no campo "Etapa".'
148
+ );
21
149
  process.exitCode = 1;
22
150
  return;
23
151
  }
@@ -57,7 +185,7 @@ export async function refresh(options = {}) {
57
185
  return;
58
186
  }
59
187
 
60
- p.intro(chalk.bold('spec-wave refresh --config'));
188
+ p.intro(chalk.bold(`spec-wave refresh${options.stages ? ' --stages' : ' --config'}`));
61
189
 
62
190
  const spinner = p.spinner();
63
191
  spinner.start('Consultando o GitHub Project...');
@@ -78,6 +206,18 @@ export async function refresh(options = {}) {
78
206
  }
79
207
  spinner.stop('Project consultado.');
80
208
 
209
+ if (options.stages) {
210
+ const ok = await syncStages(token, snapshot, options);
211
+ if (!ok) return;
212
+ // Depois de mexer no board, o .spec-wave.json guarda ids de opção velhos —
213
+ // relê o Project para que o resto do comando grave o estado novo.
214
+ snapshot = await getProjectSnapshot(token, projectId);
215
+ if (!options.config) {
216
+ p.outro('Campo "Etapa" sincronizado. Rode `spec-wave refresh --config` para atualizar o .spec-wave.json.');
217
+ return;
218
+ }
219
+ }
220
+
81
221
  // Remove campos legados (versões anteriores gravavam etapaFieldId/stageOptions soltos).
82
222
  const { etapaFieldId: _e, stageOptions: _s, ...projectRest } = config.project;
83
223
  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
+ }