@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
@@ -1,6 +1,5 @@
1
- import { execSync } from 'node:child_process';
2
1
  import path from 'node:path';
3
- import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
2
+ import { readFileSync, existsSync } from 'node:fs';
4
3
  import { resolveToken } from '../api/auth.mjs';
5
4
  import {
6
5
  getIssue, removeLabel, addLabel, commentOnIssue, listIssueComments,
@@ -16,8 +15,10 @@ import {
16
15
  } from '../lib/critique.mjs';
17
16
  import { recordUsage } from '../lib/usage-report.mjs';
18
17
  import { slugify } from '../lib/slugify.mjs';
19
- import { loadConfig, resolveFromRoot } from '../lib/project-root.mjs';
18
+ import { resolveFromRoot } from '../lib/project-root.mjs';
19
+ import { resolveFlowContext, commitGenerated } from '../lib/flow-run.mjs';
20
20
  import { buildTechContext } from '../lib/tech-context.mjs';
21
+ import { loadPrompt, toolFreeSystemPrompt } from '../lib/prompt-loader.mjs';
21
22
 
22
23
  // Aviso anexado ao comentário quando o lint de idioma ainda reprova após o
23
24
  // retry automático do generateDocument (excertos ao redor de cada vazamento).
@@ -30,25 +31,9 @@ function formatLintWarning(lintFindings) {
30
31
  return `\n\n⚠️ possíveis artefatos de idioma no documento: ${excerpts}`;
31
32
  }
32
33
 
33
- const SYSTEM_PROMPT = `Você é um Tech Lead experiente. Gere um plano técnico (plan.md) completo e detalhado, baseado ESTRITAMENTE no spec.md fornecido.
34
-
35
- O plano deve conter EXATAMENTE estas seções em português, nesta ordem:
36
- # Estratégia Técnica
37
- - Abordagem Arquitetural, Decisões-Chave e uma Matriz de Rastreabilidade (tabela) ligando cada Critério de Aceite do spec a um componente técnico.
38
- # Detalhamento da Implementação
39
- - Abra a seção com um diagrama de sequência Mermaid (bloco \`\`\`mermaid iniciado com sequenceDiagram) do fluxo principal ponta a ponta, com os componentes técnicos reais como participants (frontend, endpoints/controllers, services, banco de dados, filas). Use APENAS componentes do tech_context ou definidos neste plano; rotule as mensagens com os caminhos de endpoint e nomes de método reais, em português.
40
- - Subseções: ## Backend, ## Banco de Dados, ## Frontend, ## Infraestrutura.
41
- # Segurança e Conformidade
42
- # Estratégia de Testes
43
- - Unitários, Integração e E2E.
44
- # Rollback e Monitoramento
45
- - Plano de Rollback, Métricas Observadas e Alertas.
46
-
47
- Regras OBRIGATÓRIAS:
48
- - TODA mudança de banco, endpoint de API ou componente de UI DEVE referenciar um Critério de Aceite específico do spec.md (rastreabilidade).
49
- - Use APENAS as tecnologias e serviços listados no tech_context fornecido. Não invente APIs ou serviços inexistentes.
50
- - Forneça detalhes acionáveis: caminhos exatos de endpoints, nomes de DTOs, constraints de banco.
51
- - Responda APENAS com o conteúdo do plan.md, sem texto adicional.`;
34
+ // O prompt vive em `src/plugin/skills/plan/model-prompt.md` (sobrescrevível pelo
35
+ // projeto em `.spec-wave/prompts/plan.md`). `toolFreeSystemPrompt` remove a orientação que
36
+ // assume Read/Glob/Grep: aqui a geração é UMA chamada, sem tool loop.
52
37
 
53
38
  /**
54
39
  * Roda a crítica do plan e aplica as labels de bloqueio.
@@ -124,16 +109,9 @@ async function critiquePlan({
124
109
 
125
110
  export async function generatePlan({ issueNumber }) {
126
111
  const token = await resolveToken();
127
- const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/');
128
- const { config, root } = loadConfig();
129
-
130
- if (!owner || !repo) {
131
- throw new Error(
132
- 'GITHUB_REPOSITORY env var não definida.\n' +
133
- 'Este comando roda no GitHub Actions. Para testar localmente:\n' +
134
- ' GITHUB_REPOSITORY=owner/repo spec-wave generate-plan --issue-number 1'
135
- );
136
- }
112
+ // Roda nos dois modos: no Action (disparado por label) e na sessão local.
113
+ const { owner, repo, root, config, mode } = resolveFlowContext({ command: 'generate-plan' });
114
+ console.log(`Modo de execução: ${mode}`);
137
115
 
138
116
  console.log(`Buscando issue #${issueNumber}...`);
139
117
  const issue = await getIssue(token, owner, repo, parseInt(issueNumber, 10));
@@ -186,7 +164,8 @@ export async function generatePlan({ issueNumber }) {
186
164
  const usageEntries = [];
187
165
  try {
188
166
  console.log(`Gerando plan.md para: ${issue.title}`);
189
- const { content, lintFindings } = await generateDocument(SYSTEM_PROMPT, userContent, {
167
+ const systemPrompt = toolFreeSystemPrompt(loadPrompt('plan', { cwd: root }));
168
+ const { content, lintFindings } = await generateDocument(systemPrompt, userContent, {
190
169
  action: 'plan',
191
170
  labels: issueLabels,
192
171
  lint: { lang: TARGET_LANGUAGE },
@@ -194,17 +173,13 @@ export async function generatePlan({ issueNumber }) {
194
173
  usage: usageEntries,
195
174
  });
196
175
 
197
- mkdirSync(featureDir, { recursive: true });
198
- writeFileSync(filePath, content, 'utf-8');
199
-
200
- // Commit and push
201
- const git = (cmd) => execSync(cmd, { stdio: 'inherit' });
202
- git(`git config user.email "spec-wave[bot]@github.com"`);
203
- git(`git config user.name "spec-wave[bot]"`);
204
- git(`git add "${filePath}"`);
205
- git(`git commit -m "docs: generate plan.md for ${slug} [spec-wave]"`);
206
- git('git pull --rebase');
207
- git('git push');
176
+ const published = commitGenerated({
177
+ filePath,
178
+ content,
179
+ message: `docs: generate plan.md for ${slug} [spec-wave]`,
180
+ mode,
181
+ });
182
+ if (published.warning) console.warn(`⚠️ ${published.warning}`);
208
183
 
209
184
  // Remove trigger label
210
185
  await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:plan');
@@ -1,12 +1,12 @@
1
- import { execSync } from 'node:child_process';
2
1
  import path from 'node:path';
3
- import { mkdirSync, writeFileSync } from 'node:fs';
4
2
  import { resolveToken } from '../api/auth.mjs';
5
3
  import { getIssue, removeLabel, commentOnIssue } from '../api/github-rest.mjs';
6
4
  import { generateDocument } from '../lib/claude.mjs';
7
5
  import { recordUsage } from '../lib/usage-report.mjs';
8
6
  import { slugify } from '../lib/slugify.mjs';
9
- import { loadConfig, resolveFromRoot } from '../lib/project-root.mjs';
7
+ import { resolveFromRoot } from '../lib/project-root.mjs';
8
+ import { resolveFlowContext, commitGenerated } from '../lib/flow-run.mjs';
9
+ import { loadPrompt, toolFreeSystemPrompt } from '../lib/prompt-loader.mjs';
10
10
  import { detectIssueType } from '../lib/issue-type.mjs';
11
11
  import {
12
12
  allowsSpecPlan, SPEC_PLAN_EXCLUDED_TYPES, TARGET_LANGUAGE, labelNames,
@@ -23,40 +23,15 @@ function formatLintWarning(lintFindings) {
23
23
  return `\n\n⚠️ possíveis artefatos de idioma no documento: ${excerpts}`;
24
24
  }
25
25
 
26
- const SYSTEM_PROMPT = `Você é um Product Manager experiente. Gere uma especificação funcional (spec.md) completa para a Feature descrita pelo usuário.
27
-
28
- O spec deve conter EXATAMENTE estas seções em português, nesta ordem:
29
- # Visão Geral
30
- - Objetivo, Personas e Critérios de Sucesso como bullets.
31
- # Regras de Negócio
32
- # Fluxos
33
- - Subseções: ## Fluxo Principal (Happy Path), ## Fluxos Alternativos, ## Cenários de Erro.
34
- - O Fluxo Principal DEVE conter, além da descrição passo a passo, um diagrama de sequência Mermaid (bloco \`\`\`mermaid iniciado com sequenceDiagram) mostrando a interação entre as personas (actor) e o sistema (participant). Rotule mensagens e notas em português.
35
- - Cubra os Fluxos Alternativos e Cenários de Erro relevantes no mesmo diagrama usando blocos alt/opt/break — ou, se ficarem complexos, em um segundo diagrama na subseção correspondente.
36
- # Critérios de Aceite
37
- - OBRIGATORIAMENTE no formato Gherkin, dentro de um bloco \`\`\`gherkin com Given/When/Then. Um cenário por critério.
38
- # Dependências
39
- - Subdivida em Internas e Externas.
40
- # Requisitos Não-Funcionais
41
- - Performance, Segurança e Usabilidade.
42
-
43
- Regras:
44
- - NÃO invente regras de negócio. Se faltar informação, marque explicitamente com "[TODO: requer esclarecimento do PO]".
45
- - Seja específico e detalhado em cada seção.
46
- - Responda APENAS com o conteúdo do spec.md, sem texto adicional.`;
26
+ // O prompt vive em `src/plugin/skills/spec/model-prompt.md` (sobrescrevível pelo
27
+ // projeto em `.spec-wave/prompts/spec.md`). `toolFreeSystemPrompt` remove a orientação que assume
28
+ // Read/Glob/Grep: aqui a geração é UMA chamada de completions, sem tool loop.
47
29
 
48
30
  export async function generateSpec({ issueNumber }) {
49
31
  const token = await resolveToken();
50
- const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/');
51
- const { root } = loadConfig();
52
-
53
- if (!owner || !repo) {
54
- throw new Error(
55
- 'GITHUB_REPOSITORY env var não definida.\n' +
56
- 'Este comando roda no GitHub Actions. Para testar localmente:\n' +
57
- ' GITHUB_REPOSITORY=owner/repo spec-wave generate-spec --issue-number 1'
58
- );
59
- }
32
+ // Roda nos dois modos: no Action (disparado por label) e na sessão local.
33
+ const { owner, repo, root, mode } = resolveFlowContext({ command: 'generate-spec' });
34
+ console.log(`Modo de execução: ${mode}`);
60
35
 
61
36
  console.log(`Buscando issue #${issueNumber}...`);
62
37
  const issue = await getIssue(token, owner, repo, parseInt(issueNumber, 10));
@@ -103,7 +78,8 @@ export async function generateSpec({ issueNumber }) {
103
78
  const usageEntries = [];
104
79
  try {
105
80
  console.log(`Gerando spec.md para: ${issue.title}`);
106
- const { content, lintFindings } = await generateDocument(SYSTEM_PROMPT, userContent, {
81
+ const systemPrompt = toolFreeSystemPrompt(loadPrompt('spec', { cwd: root }));
82
+ const { content, lintFindings } = await generateDocument(systemPrompt, userContent, {
107
83
  action: 'spec',
108
84
  labels: issueLabels,
109
85
  lint: { lang: TARGET_LANGUAGE },
@@ -111,17 +87,13 @@ export async function generateSpec({ issueNumber }) {
111
87
  usage: usageEntries,
112
88
  });
113
89
 
114
- mkdirSync(featureDir, { recursive: true });
115
- writeFileSync(filePath, content, 'utf-8');
116
-
117
- // Commit and push
118
- const git = (cmd) => execSync(cmd, { stdio: 'inherit' });
119
- git(`git config user.email "spec-wave[bot]@github.com"`);
120
- git(`git config user.name "spec-wave[bot]"`);
121
- git(`git add "${filePath}"`);
122
- git(`git commit -m "docs: generate spec.md for ${slug} [spec-wave]"`);
123
- git('git pull --rebase');
124
- git('git push');
90
+ const published = commitGenerated({
91
+ filePath,
92
+ content,
93
+ message: `docs: generate spec.md for ${slug} [spec-wave]`,
94
+ mode,
95
+ });
96
+ if (published.warning) console.warn(`⚠️ ${published.warning}`);
125
97
 
126
98
  // Remove trigger label
127
99
  await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:spec');
@@ -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
  }