@spec-wave/cli 0.15.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 (75) hide show
  1. package/README.md +1 -0
  2. package/bin/spec-wave.mjs +41 -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 +8 -0
  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 +16 -3
  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/prompt-loader.mjs +257 -0
  40. package/src/lib/skill-file.mjs +35 -0
  41. package/src/plugin/.claude-plugin/plugin.json +20 -0
  42. package/src/plugin/README.md +73 -0
  43. package/src/plugin/skills/bug/SKILL.md +60 -0
  44. package/src/plugin/skills/bug/model-prompt.critique.md +48 -0
  45. package/src/plugin/skills/bug/model-prompt.md +74 -0
  46. package/src/plugin/skills/decompose/SKILL.md +111 -0
  47. package/src/plugin/skills/decompose/model-prompt.critique.md +46 -0
  48. package/src/plugin/skills/decompose/model-prompt.feature.md +69 -0
  49. package/src/plugin/skills/decompose/model-prompt.rfc.md +52 -0
  50. package/src/plugin/skills/doctor/SKILL.md +51 -0
  51. package/src/plugin/skills/fix-pr/SKILL.md +130 -0
  52. package/src/plugin/skills/implement/SKILL.md +102 -0
  53. package/src/plugin/skills/info/SKILL.md +40 -0
  54. package/src/plugin/skills/issue/SKILL.md +63 -0
  55. package/src/plugin/skills/move/SKILL.md +52 -0
  56. package/src/plugin/skills/order/SKILL.md +36 -0
  57. package/src/plugin/skills/plan/SKILL.md +53 -0
  58. package/src/plugin/skills/plan/model-prompt.critique.md +44 -0
  59. package/src/plugin/skills/plan/model-prompt.md +59 -0
  60. package/src/plugin/skills/plan/reference/tech-context.md +56 -0
  61. package/src/plugin/skills/ready/SKILL.md +44 -0
  62. package/src/plugin/skills/rfc/SKILL.md +47 -0
  63. package/src/plugin/skills/setup/SKILL.md +67 -0
  64. package/src/plugin/skills/spec/SKILL.md +37 -0
  65. package/src/plugin/skills/spec/model-prompt.md +61 -0
  66. package/src/plugin/skills/story/SKILL.md +49 -0
  67. package/src/plugin/skills/task/SKILL.md +41 -0
  68. package/src/plugin/skills/triage/SKILL.md +52 -0
  69. package/src/plugin/skills/uninstall/SKILL.md +43 -0
  70. package/src/plugin/skills/update/SKILL.md +51 -0
  71. package/src/plugin/skills/workflow/SKILL.md +154 -0
  72. package/src/templates/skill/SKILL.md +54 -4
  73. package/src/templates/workflows/generate-bug.yml +36 -0
  74. package/src/templates/workflows/validate.yml +2 -1
  75. package/src/ui/wizard.mjs +5 -2
@@ -6,11 +6,13 @@ import path from 'node:path';
6
6
  import { resolveToken } from '../api/auth.mjs';
7
7
  import {
8
8
  CONFIG_FILE, STAGE_DEVELOPMENT, STAGE_CODE_REVIEW, STAGE_DONE, STAGE_ORDER,
9
- PROGRESS_TODO, PROGRESS_IN_PROGRESS, PROGRESS_DONE,
9
+ PROGRESS_TODO, PROGRESS_IN_PROGRESS, PROGRESS_DONE, labelNames,
10
10
  } from '../config.mjs';
11
11
  import { getIssue, listIssueComments, listBlockedBy } from '../api/github-rest.mjs';
12
12
  import { listSubIssues, getIssueParent, addProjectItem, getItemSingleSelectValue } from '../api/github-graphql.mjs';
13
13
  import { detectIssueType } from '../lib/issue-type.mjs';
14
+ import { bugDocPaths } from '../lib/bug-doc.mjs';
15
+ import { buildBugContext } from '../lib/bug-context.mjs';
14
16
  import { slugify } from '../lib/slugify.mjs';
15
17
  import { parseDependencies, orderStories, formatDependencyLine } from '../lib/dependencies.mjs';
16
18
  import { loadProjectConfig, resolveField } from '../lib/board.mjs';
@@ -360,6 +362,102 @@ function renderCommand(template, vars) {
360
362
  // Modo Feature: avalia as Stories da Feature (dependências + Etapa no board),
361
363
  // pula as já implementadas (Code Review+) e monta UM contexto único com todas
362
364
  // as pendentes em ordem topológica — spec-kit acionado uma vez.
365
+ /**
366
+ * Modo Bug (RFC-004 §7.1): sem tasks, sem spec/plan, sem Feature-pai a arrastar.
367
+ *
368
+ * O bug.md, quando existe, entra como HIPÓTESE — foi escrito por IA sem
369
+ * executar código, e o contexto diz isso explicitamente ao executor. Quando não
370
+ * existe (o caso do P0, que dispensa o documento), o contexto assume a
371
+ * investigação inteira.
372
+ */
373
+ async function implementBug({ token, owner, repo, config, bug, dryRun, repoRoot }) {
374
+ const issueNumber = bug.number;
375
+ const severity = (labelNames(bug).find(n => /^P[0-3]$/.test(n))) || null;
376
+
377
+ // Item afetado (best-effort): dá ao executor o contexto do que quebrou. O pai
378
+ // de um Bug pode ser Feature OU Story — o tipo vem do prefixo do título.
379
+ let parent = null;
380
+ try {
381
+ const p0 = await getIssueParent(token, bug.node_id);
382
+ if (p0?.number) {
383
+ parent = { number: p0.number, title: p0.title, kind: detectIssueType(p0) || 'Item' };
384
+ }
385
+ } catch {
386
+ // sem pai legível — bug órfão é caso previsto (reporte de suporte)
387
+ }
388
+
389
+ // bug.md do repositório, se já foi gerado.
390
+ const { fileAbs, fileRel } = bugDocPaths(bug.title, repoRoot);
391
+ let bugDoc = null;
392
+ if (existsSync(fileAbs)) {
393
+ bugDoc = readFileSync(fileAbs, 'utf-8');
394
+ p.log.info(`bug.md encontrado em ${chalk.cyan(fileRel)}.`);
395
+ } else {
396
+ p.log.warn(`Sem bug.md em ${fileRel} — o contexto assume a investigação inteira.`);
397
+ }
398
+
399
+ // Board: Bug → Desenvolvimento (In Progress). Não move a Feature-pai.
400
+ await applyBoardMoves({
401
+ token,
402
+ moves: planBoardMoves('start', { bug: { nodeId: bug.node_id, number: issueNumber } }),
403
+ cwd: repoRoot || process.cwd(),
404
+ dryRun,
405
+ });
406
+
407
+ const comments = [];
408
+ const all = await listIssueComments(token, owner, repo, issueNumber).catch(() => []);
409
+ if (all.length > 0) {
410
+ const items = all.slice(-MAX_COMMENTS_PER_ISSUE).map(c => ({
411
+ ...c,
412
+ body: c.body.length > MAX_COMMENT_CHARS
413
+ ? `${c.body.slice(0, MAX_COMMENT_CHARS)}…[truncado]`
414
+ : c.body,
415
+ }));
416
+ comments.push({ issueNumber, kind: 'Bug', total: all.length, items });
417
+ }
418
+
419
+ let codeDigest = null;
420
+ try {
421
+ codeDigest = await buildCodeDigest({ sinceIso: bug.created_at || null, paths: [] });
422
+ } catch {
423
+ codeDigest = null;
424
+ }
425
+
426
+ const blockedByWarnings = [];
427
+ try {
428
+ const blocked = await listBlockedBy(token, owner, repo, issueNumber).catch(() => []);
429
+ for (const b of blocked) {
430
+ if (b?.state !== 'closed') blockedByWarnings.push(`#${b.number} — ${b.title}`);
431
+ }
432
+ } catch {
433
+ // dependências não legíveis — segue
434
+ }
435
+
436
+ const context = buildBugContext({
437
+ bug: { number: issueNumber, title: bug.title, body: bug.body || '' },
438
+ bugDoc, parent, comments, codeDigest, blockedByWarnings, severity,
439
+ });
440
+
441
+ await writeContextAndRunSpecKit({
442
+ config,
443
+ issueNumber,
444
+ type: 'Bug',
445
+ title: bug.title,
446
+ specPlan: { spec: null, plan: null, specPath: null, planPath: null },
447
+ context,
448
+ dryRun,
449
+ outroSuccess: `Bug #${issueNumber} corrigido — abra o PR com \`Fixes #${issueNumber}\`.`,
450
+ onSuccess: async () => {
451
+ await applyBoardMoves({
452
+ token,
453
+ moves: planBoardMoves('success', { bug: { nodeId: bug.node_id, number: issueNumber } }),
454
+ cwd: repoRoot || process.cwd(),
455
+ dryRun,
456
+ });
457
+ },
458
+ });
459
+ }
460
+
363
461
  async function implementFeature({ token, owner, repo, config, feature, featureDirOpt, dryRun, repoRoot }) {
364
462
  // F1. Stories (sub-issues) da Feature.
365
463
  const subs = await listSubIssues(token, feature.node_id).catch(() => []);
@@ -630,9 +728,14 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
630
728
  // Modo Feature: Stories pendentes em ordem de dependência, contexto único.
631
729
  await implementFeature({ token, owner, repo, config, feature: issue, featureDirOpt, dryRun, repoRoot });
632
730
  return;
731
+ } else if (type === 'Bug') {
732
+ // Modo Bug: sem tasks e sem spec/plan — o trabalho é investigar antes de
733
+ // corrigir, e o contexto impõe essa ordem.
734
+ await implementBug({ token, owner, repo, config, bug: issue, dryRun, repoRoot });
735
+ return;
633
736
  } else {
634
737
  p.log.error(
635
- `implement aceita Feature, Story ou Task. Issue #${issueNumber} é do tipo ${type || 'desconhecido'}.`
738
+ `implement aceita Feature, Story, Task ou Bug. Issue #${issueNumber} é do tipo ${type || 'desconhecido'}.`
636
739
  );
637
740
  process.exitCode = 1;
638
741
  return;
@@ -9,7 +9,7 @@ import { setupProject } from '../setup/project.mjs';
9
9
  import { setupLabels } from '../setup/labels.mjs';
10
10
  import { setupFiles } from '../setup/files.mjs';
11
11
  import { getFileContent } from '../api/github-rest.mjs';
12
- import { CONFIG_FILE, AI_PROVIDERS, getProvider, DEFAULT_PROVIDER, PORTAL_URL, WORKFLOW_FILES, ISSUE_TEMPLATE_FILES } from '../config.mjs';
12
+ import { CONFIG_FILE, AI_PROVIDERS, getProvider, DEFAULT_PROVIDER, PORTAL_URL, WORKFLOW_FILES, ISSUE_TEMPLATE_FILES, STATUS_OPTIONS, ALL_LABELS } from '../config.mjs';
13
13
 
14
14
  const __dir = path.dirname(fileURLToPath(import.meta.url));
15
15
  const pkg = JSON.parse(readFileSync(path.join(__dir, '..', '..', 'package.json'), 'utf-8'));
@@ -143,7 +143,7 @@ export async function init(options) {
143
143
  labelSpinner.start('Criando labels...');
144
144
  try {
145
145
  await setupLabels(token, owner, repo, labelSpinner);
146
- labelSpinner.stop('Labels criadas (16 labels)');
146
+ labelSpinner.stop(`Labels criadas (${ALL_LABELS.length} labels)`);
147
147
  } catch (err) {
148
148
  labelSpinner.stop('');
149
149
  p.log.error(`Erro ao criar labels: ${err.message}`);
@@ -204,7 +204,7 @@ export async function init(options) {
204
204
  }
205
205
 
206
206
  p.note(
207
- 'As 12 colunas do RFC-001 foram criadas no campo "Etapa".\n' +
207
+ `As ${STATUS_OPTIONS.length} colunas do RFC-001 foram criadas no campo "Etapa".\n` +
208
208
  'Para usá-las como colunas do board:\n' +
209
209
  ' 1. Abra o projeto no GitHub\n' +
210
210
  ' 2. Clique em "..." → "Settings" da view de Board\n' +
@@ -1,10 +1,11 @@
1
1
  import * as p from '@clack/prompts';
2
2
  import chalk from 'chalk';
3
- import yaml from 'js-yaml';
4
3
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
5
4
  import { fileURLToPath } from 'node:url';
6
5
  import { homedir } from 'node:os';
7
6
  import path from 'node:path';
7
+ import { parseSkill } from '../lib/skill-file.mjs';
8
+ import { listPluginSkills, planPluginSkillFiles } from '../lib/plugin-skills.mjs';
8
9
 
9
10
  const __dir = path.dirname(fileURLToPath(import.meta.url));
10
11
  // Fonte única da skill, publicada via "files": ["src"] no package.json.
@@ -33,6 +34,17 @@ export const TARGETS = [
33
34
  project: '.claude/skills/spec-wave/SKILL.md',
34
35
  global: '.claude/skills/spec-wave/SKILL.md',
35
36
  },
37
+ {
38
+ key: 'codex',
39
+ name: 'Codex CLI',
40
+ // Codex não tem conceito de plugin: ele varre diretórios de skills. Então o
41
+ // MESMO conteúdo do plugin é copiado skill a skill para `.agents/skills`,
42
+ // que é o caminho que o Codex lê no repo (e `~/.agents/skills` no usuário).
43
+ format: 'skills-dir',
44
+ detect: ['.codex', '.agents'],
45
+ project: path.join('.agents', 'skills'),
46
+ global: path.join('.agents', 'skills'),
47
+ },
36
48
  {
37
49
  key: 'opencode',
38
50
  name: 'opencode',
@@ -86,22 +98,14 @@ export const TARGETS = [
86
98
 
87
99
  const TARGET_BY_KEY = new Map(TARGETS.map((t) => [t.key, t]));
88
100
 
89
- // Separa o frontmatter YAML do corpo do SKILL.md. Retorna { meta, body }.
90
- export function parseSkill(raw) {
91
- const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
92
- if (!match) return { meta: {}, frontmatter: '', body: raw.trim() };
93
- let meta = {};
94
- try {
95
- meta = yaml.load(match[1]) || {};
96
- } catch {
97
- meta = {};
98
- }
99
- return { meta, frontmatter: match[1], body: match[2].trim() };
100
- }
101
+ // Separa o frontmatter YAML do corpo do SKILL.md. Implementação em
102
+ // `lib/skill-file.mjs` (compartilhada com o loader das skills de IA);
103
+ // re-exportada aqui porque `update.mjs` e os testes a importam deste módulo.
104
+ export { parseSkill };
101
105
 
102
106
  // Banner de versão inserido no topo do corpo da skill instalada. O agente lê
103
107
  // esta linha e, se `npx @spec-wave/cli@latest --version` for maior, orienta reinstalar.
104
- function versionBanner(version) {
108
+ export function versionBanner(version) {
105
109
  return (
106
110
  `> ⚙️ **spec-wave skill v${version}** — esta skill é uma cópia estática. ` +
107
111
  'Se `npx @spec-wave/cli@latest --version` indicar uma versão maior, ela está ' +
@@ -182,6 +186,20 @@ export function isDetected(target, baseDir) {
182
186
  // 'ausente' | 'bloco ausente' | 'desatualizada' — ou null se está em dia com a
183
187
  // versão empacotada na CLI. Compartilhado entre `update` e `info`.
184
188
  export function skillCopyReason(dest, parsed) {
189
+ // `skills-dir` não é um arquivo só: o destino é um diretório com uma pasta
190
+ // por skill. Diretório inteiro ausente conta como 'ausente'; qualquer arquivo
191
+ // faltando ou divergente conta como 'desatualizada' (é o mesmo remédio —
192
+ // reinstalar —, então não vale distinguir os dois casos).
193
+ if (dest.format === 'skills-dir') {
194
+ if (!existsSync(dest.path)) return 'ausente';
195
+ const files = planPluginSkillFiles(dest.path, CLI_VERSION, versionBanner);
196
+ if (!files.length) return null;
197
+ const stale = files.some(
198
+ (f) => !existsSync(f.path) || readFileSync(f.path, 'utf-8') !== f.content,
199
+ );
200
+ return stale ? 'desatualizada' : null;
201
+ }
202
+
185
203
  const desired = renderContent(dest.format, parsed, CLI_VERSION);
186
204
  const existing = existsSync(dest.path) ? readFileSync(dest.path, 'utf-8') : null;
187
205
  if (existing === null) return 'ausente';
@@ -312,7 +330,12 @@ export async function installSkill(options = {}) {
312
330
  if (options.dryRun) {
313
331
  p.note(
314
332
  jobs
315
- .map((j) => `${chalk.dim(j.target.name.padEnd(20))} ${j.dest.path} ${chalk.dim(`(${j.dest.format})`)}`)
333
+ .map((j) => {
334
+ const head = `${chalk.dim(j.target.name.padEnd(20))} ${j.dest.path} ${chalk.dim(`(${j.dest.format})`)}`;
335
+ if (j.dest.format !== 'skills-dir') return head;
336
+ const names = listPluginSkills().map((s) => s.name);
337
+ return `${head}\n${chalk.dim(` ${names.length} skills: ${names.join(', ')}`)}`;
338
+ })
316
339
  .join('\n'),
317
340
  `Dry-run — nada será gravado (escopo: ${scopeLabel})`,
318
341
  );
@@ -323,6 +346,34 @@ export async function installSkill(options = {}) {
323
346
  // 4) Gravar cada destino.
324
347
  const written = [];
325
348
  for (const { target, dest } of jobs) {
349
+ // `skills-dir` grava N arquivos (uma pasta por skill do plugin) em vez de
350
+ // um. Sobrescrever aqui é seguro sem confirmar por arquivo: o destino é um
351
+ // diretório inteiro do spec-wave, não um arquivo que o usuário mantém.
352
+ if (dest.format === 'skills-dir') {
353
+ const files = planPluginSkillFiles(dest.path, pkg.version, versionBanner);
354
+ if (!files.length) {
355
+ p.log.error(`${target.name}: nenhuma skill encontrada no plugin empacotado — pulado.`);
356
+ continue;
357
+ }
358
+ if (existsSync(dest.path) && !options.force && !options.yes) {
359
+ const ok = await p.confirm({
360
+ message: `${target.name}: ${dest.path} já existe. Sobrescrever as skills do spec-wave?`,
361
+ initialValue: true,
362
+ });
363
+ if (p.isCancel(ok) || !ok) {
364
+ p.log.info(`${target.name}: mantido (não sobrescrito).`);
365
+ continue;
366
+ }
367
+ }
368
+ for (const file of files) {
369
+ mkdirSync(path.dirname(file.path), { recursive: true });
370
+ writeFileSync(file.path, file.content, 'utf-8');
371
+ }
372
+ const count = new Set(files.map((f) => f.skill)).size;
373
+ written.push({ target, dest, count });
374
+ continue;
375
+ }
376
+
326
377
  const content =
327
378
  dest.format === 'agents'
328
379
  ? mergeAgentsFile(dest.path, renderContent('agents', parsed, pkg.version))
@@ -352,7 +403,12 @@ export async function installSkill(options = {}) {
352
403
  }
353
404
 
354
405
  p.note(
355
- written.map((w) => `${chalk.green('✓')} ${chalk.bold(w.target.name)}\n ${chalk.dim(w.dest.path)}`).join('\n'),
406
+ written
407
+ .map((w) => {
408
+ const suffix = w.count ? chalk.dim(` (${w.count} skills)`) : '';
409
+ return `${chalk.green('✓')} ${chalk.bold(w.target.name)}${suffix}\n ${chalk.dim(w.dest.path)}`;
410
+ })
411
+ .join('\n'),
356
412
  `Skill v${pkg.version} instalada (escopo: ${scopeLabel})`,
357
413
  );
358
414
  p.outro(
@@ -2,13 +2,11 @@ import * as p from '@clack/prompts';
2
2
  import chalk from 'chalk';
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { resolveToken } from '../api/auth.mjs';
5
- import { CONFIG_FILE, STATUS_OPTIONS, PRIORITY_LABELS, WORK_ITEM_TYPES } from '../config.mjs';
5
+ import { CONFIG_FILE, PRIORITY_LABELS, WORK_ITEM_TYPES, initialStageForType } from '../config.mjs';
6
6
  import { findConfigPath } from '../lib/project-root.mjs';
7
7
  import { createIssue, getIssue } from '../api/github-rest.mjs';
8
8
  import { addProjectItem, setItemSingleSelect, getSingleSelectField, addSubIssue } from '../api/github-graphql.mjs';
9
9
 
10
- // Etapa inicial de todo work item recém-criado (📥 Backlog).
11
- const INITIAL_STAGE = STATUS_OPTIONS[0].name;
12
10
  const VALID_PRIORITIES = PRIORITY_LABELS.map(l => l.name);
13
11
 
14
12
  // Resolve o tipo informado (case-insensitive) para o nome canônico (ex.: "Feature").
@@ -160,18 +158,22 @@ export async function issue(options) {
160
158
  return;
161
159
  }
162
160
 
161
+ // Etapa de nascimento: Backlog para quase todo tipo, 🐞 Triagem para Bug
162
+ // (✅ Ready quando o Bug já nasce P0). Ver initialStageForType.
163
+ const initialStage = initialStageForType(type, options.priority || null);
164
+
163
165
  const boardSpinner = p.spinner();
164
166
  boardSpinner.start('Adicionando ao Project...');
165
167
  try {
166
168
  const itemId = await addProjectItem(token, project.id, created.nodeId);
167
169
 
168
- const stageOk = await setField(token, project, itemId, 'Etapa', INITIAL_STAGE);
170
+ const stageOk = await setField(token, project, itemId, 'Etapa', initialStage);
169
171
  const typeOk = await setField(token, project, itemId, 'Work Item Type', type);
170
172
  if (options.priority) await setField(token, project, itemId, 'Priority', options.priority);
171
173
  if (options.area) await setField(token, project, itemId, 'Area', options.area);
172
174
 
173
- boardSpinner.stop(`Adicionada ao Project${stageOk ? ` em "${INITIAL_STAGE}"` : ''}.`);
174
- if (!stageOk) p.log.warn(`Não foi possível definir a Etapa "${INITIAL_STAGE}" (campo não encontrado).`);
175
+ boardSpinner.stop(`Adicionada ao Project${stageOk ? ` em "${initialStage}"` : ''}.`);
176
+ if (!stageOk) p.log.warn(`Não foi possível definir a Etapa "${initialStage}" (campo não encontrado).`);
175
177
  if (!typeOk) p.log.warn(`Não foi possível definir o Work Item Type "${type}" (campo não encontrado).`);
176
178
  } catch (err) {
177
179
  boardSpinner.stop('');
@@ -185,7 +187,7 @@ export async function issue(options) {
185
187
  Epic: `Próximo: crie Features sob este Epic com \`spec-wave feature --parent ${created.number} --title "..."\`.`,
186
188
  };
187
189
  p.outro(
188
- `${chalk.green('✓')} ${type} #${created.number} criado em "${INITIAL_STAGE}"${parentLine}.\n` +
190
+ `${chalk.green('✓')} ${type} #${created.number} criado em "${initialStage}"${parentLine}.\n` +
189
191
  ` ${hints[type] || ''}`
190
192
  );
191
193
  }
@@ -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 = {