@spec-wave/cli 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +39 -6
  2. package/bin/spec-wave.mjs +17 -1
  3. package/package.json +1 -1
  4. package/src/api/github-rest.mjs +198 -2
  5. package/src/commands/code-review.mjs +5 -8
  6. package/src/commands/decompose.mjs +412 -130
  7. package/src/commands/dev-agent.mjs +3 -2
  8. package/src/commands/doctor.mjs +239 -9
  9. package/src/commands/generate-plan.mjs +105 -30
  10. package/src/commands/generate-spec.mjs +17 -5
  11. package/src/commands/implement.mjs +39 -15
  12. package/src/commands/info.mjs +4 -3
  13. package/src/commands/issue.mjs +4 -4
  14. package/src/commands/move.mjs +162 -0
  15. package/src/commands/order.mjs +1 -12
  16. package/src/commands/qa.mjs +5 -8
  17. package/src/commands/refresh.mjs +4 -3
  18. package/src/commands/story.mjs +1 -12
  19. package/src/commands/task.mjs +1 -11
  20. package/src/commands/update.mjs +372 -71
  21. package/src/commands/validate.mjs +37 -22
  22. package/src/config.mjs +40 -1
  23. package/src/lib/board.mjs +88 -26
  24. package/src/lib/claude.mjs +315 -70
  25. package/src/lib/critique.mjs +391 -91
  26. package/src/lib/decomposition-doc.mjs +451 -0
  27. package/src/lib/implement-board.mjs +14 -1
  28. package/src/lib/pr-branch.mjs +267 -0
  29. package/src/lib/project-root.mjs +93 -0
  30. package/src/lib/templates.mjs +53 -0
  31. package/src/setup/files.mjs +3 -10
  32. package/src/templates/skill/SKILL.md +158 -30
  33. package/src/templates/workflows/code-review.yml +1 -1
  34. package/src/templates/workflows/decompose.yml +20 -6
  35. package/src/templates/workflows/generate-plan.yml +1 -1
  36. package/src/templates/workflows/generate-spec.yml +1 -1
  37. package/src/templates/workflows/qa.yml +1 -1
  38. package/src/templates/workflows/validate.yml +1 -1
@@ -3,46 +3,58 @@ import path from 'node:path';
3
3
  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
- import { CONFIG_FILE, LABEL_CRITIQUE_FAILED, REQUIRED_PLAN_SECTIONS, REQUIRED_SPEC_SECTIONS } from '../config.mjs';
6
+ import {
7
+ CONFIG_FILE, LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN,
8
+ REQUIRED_PLAN_SECTIONS, REQUIRED_SPEC_SECTIONS, labelNames,
9
+ } from '../config.mjs';
7
10
  import { findIncompleteDocSigns } from '../lib/doc-completeness.mjs';
11
+ import { loadConfig } from '../lib/project-root.mjs';
8
12
 
9
13
  export async function validate({ issueNumber }) {
10
14
  const token = await resolveToken();
11
15
  const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
12
- const configPath = path.join(process.cwd(), CONFIG_FILE);
13
- let cfg = {};
14
- try { if (existsSync(configPath)) cfg = JSON.parse(readFileSync(configPath, 'utf-8')); } catch {}
15
- const owner = envOwner || cfg.owner;
16
- const repo = envRepo || cfg.repo;
16
+ const { config: cfg, root } = loadConfig();
17
+ const owner = envOwner || cfg?.owner;
18
+ const repo = envRepo || cfg?.repo;
17
19
 
18
20
  if (!owner || !repo) {
19
21
  throw new Error(
20
22
  'Não foi possível determinar owner/repo.\n' +
21
- 'Defina GITHUB_REPOSITORY=owner/repo ou rode o comando dentro de um repositório com .spec-wave.json.'
23
+ `Defina GITHUB_REPOSITORY=owner/repo ou rode o comando dentro de um repositório com ${CONFIG_FILE}.`
22
24
  );
23
25
  }
24
26
 
25
27
  const issue = await getIssue(token, owner, repo, parseInt(issueNumber, 10));
26
28
  const slug = slugify(issue.title);
27
- const featureDir = `docs/features/${slug}`;
29
+ // Ancorado na RAIZ do repo, não no cwd: rodar de um subdiretório encontra o
30
+ // config subindo na árvore e precisa encontrar os documentos no mesmo lugar.
31
+ const featureRel = `docs/features/${slug}`;
32
+ const featureDir = path.resolve(root || process.cwd(), featureRel);
28
33
 
29
34
  const errors = [];
30
35
 
31
36
  // Bloqueio da crítica adversarial: enquanto a label critique-failed estiver
32
37
  // na issue, o ready não é liberado — a correção dos documentos é manual.
33
- const labelNames = (issue.labels || []).map((l) => (typeof l === 'string' ? l : l.name));
34
- const critiqueFailed = labelNames.includes(LABEL_CRITIQUE_FAILED);
38
+ const names = labelNames(issue);
39
+ const critiqueFailed = names.includes(LABEL_CRITIQUE_FAILED);
40
+ const needsHuman = names.includes(LABEL_NEEDS_HUMAN);
35
41
  if (critiqueFailed) {
36
42
  errors.push(
37
43
  '🔎 A crítica adversarial apontou contradições GRAVES (veja o comentário na issue). ' +
38
44
  `Corrija os documentos e remova a label \`${LABEL_CRITIQUE_FAILED}\` para liberar o ready.`
39
45
  );
40
46
  }
47
+ if (needsHuman) {
48
+ errors.push(
49
+ `🛑 A crítica reprovou repetidas vezes e a label \`${LABEL_NEEDS_HUMAN}\` foi aplicada. ` +
50
+ 'Uma pessoa precisa revisar os documentos e remover a label para liberar o ready.'
51
+ );
52
+ }
41
53
 
42
54
  // Check plan.md
43
- const planPath = `${featureDir}/plan.md`;
55
+ const planPath = path.join(featureDir, 'plan.md');
44
56
  if (!existsSync(planPath)) {
45
- errors.push('❌ `plan.md` não encontrado em `' + planPath + '`');
57
+ errors.push('❌ `plan.md` não encontrado em `' + `${featureRel}/plan.md` + '`');
46
58
  } else {
47
59
  const planContent = readFileSync(planPath, 'utf-8');
48
60
  for (const section of REQUIRED_PLAN_SECTIONS) {
@@ -56,9 +68,9 @@ export async function validate({ issueNumber }) {
56
68
  }
57
69
 
58
70
  // Check spec.md
59
- const specPath = `${featureDir}/spec.md`;
71
+ const specPath = path.join(featureDir, 'spec.md');
60
72
  if (!existsSync(specPath)) {
61
- errors.push('❌ `spec.md` não encontrado em `' + specPath + '`');
73
+ errors.push('❌ `spec.md` não encontrado em `' + `${featureRel}/spec.md` + '`');
62
74
  } else {
63
75
  const specContent = readFileSync(specPath, 'utf-8');
64
76
  for (const section of REQUIRED_SPEC_SECTIONS) {
@@ -83,11 +95,13 @@ export async function validate({ issueNumber }) {
83
95
  errors.join('\n') +
84
96
  `\n\nCorreija os problemas e adicione novamente a label \`spec-wave:ready\`.`
85
97
  );
86
- // Quando a ÚNICA falha é a da crítica adversarial, os documentos existem e
87
- // estão estruturalmente válidos — só precisam de correção manual. Nesse
88
- // caso NÃO devolvemos a feature para a etapa de spec (spec-wave:spec).
89
- const onlyCritiqueFailed = critiqueFailed && errors.length === 1;
90
- if (!onlyCritiqueFailed) {
98
+ // Quando as ÚNICAS falhas são os portões humanos (crítica grave / revisão
99
+ // exigida), os documentos existem e estão estruturalmente válidos — só
100
+ // precisam de correção manual. Nesse caso NÃO devolvemos a feature para a
101
+ // etapa de spec (spec-wave:spec).
102
+ const humanGates = (critiqueFailed ? 1 : 0) + (needsHuman ? 1 : 0);
103
+ const onlyHumanGates = humanGates > 0 && errors.length === humanGates;
104
+ if (!onlyHumanGates) {
91
105
  // Send back to spec stage
92
106
  await addLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:spec');
93
107
  }
@@ -101,9 +115,10 @@ export async function validate({ issueNumber }) {
101
115
  await commentOnIssue(
102
116
  token, owner, repo, parseInt(issueNumber, 10),
103
117
  `✅ **Validação concluída com sucesso!**\n\n` +
104
- `- [\`${specPath}\`](${specPath}) ✓\n` +
105
- `- [\`${planPath}\`](${planPath}) ✓\n\n` +
106
- `A Feature está pronta para decomposição. Use:\n` +
118
+ `- [\`${featureRel}/spec.md\`](${featureRel}/spec.md) ✓\n` +
119
+ `- [\`${featureRel}/plan.md\`](${featureRel}/plan.md) ✓\n\n` +
120
+ `A Feature está pronta para a decomposição. O próximo passo gera o **rascunho** ` +
121
+ `em \`${featureRel}/decomposition.md\` para revisão (nada é criado ainda):\n` +
107
122
  `\`\`\`\ngh issue edit ${issueNumber} --add-label "spec-wave:decompose"\n\`\`\``
108
123
  );
109
124
 
package/src/config.mjs CHANGED
@@ -37,6 +37,17 @@ export const DEFAULT_PROVIDER = 'anthropic';
37
37
  // por resolveAiConfig() em src/lib/claude.mjs.
38
38
  export const AI_ACTIONS = ['spec', 'plan', 'decompose', 'critique'];
39
39
 
40
+ // Override de modelo POR EXECUÇÃO: a label `spec-wave:model:<apelido>` na issue
41
+ // aponta para uma entrada de `ai.modelAliases` do .spec-wave.json. Serve para
42
+ // reprocessar um caso difícil num modelo mais forte sem editar a configuração do
43
+ // repositório inteiro. Resolvido por resolveModelLabel() em src/lib/claude.mjs.
44
+ export const MODEL_LABEL_PREFIX = 'spec-wave:model:';
45
+
46
+ // Quantas críticas seguidas podem reprovar a mesma issue antes de exigir revisão
47
+ // humana. Na última tentativa a IA NÃO é chamada: o fluxo aplica
48
+ // `spec-wave:needs-human` e para. Ajustável por `ai.maxCritiqueAttempts`.
49
+ export const DEFAULT_MAX_CRITIQUE_ATTEMPTS = 3;
50
+
40
51
  // Idioma-alvo de todos os documentos gerados por IA (spec/plan/decompose/critique).
41
52
  // Usado pelo lint de saída (src/lib/output-lint.mjs) para detectar vazamento
42
53
  // de caracteres de outros alfabetos.
@@ -180,22 +191,50 @@ export const PRIORITY_LABELS = [
180
191
  { name: 'P3', color: 'EDEDED', description: 'Baixa' },
181
192
  ];
182
193
 
194
+ // Labels de gatilho do fluxo de decomposição. São DUAS etapas: `:decompose`
195
+ // gera (ou re-critica) o rascunho em decomposition.md sem criar nada, e
196
+ // `:decompose-apply` cria as Stories/Tasks a partir do rascunho já revisado.
197
+ // A separação existe porque a crítica precisa de um artefato ESTÁVEL para
198
+ // apontar ("Story 3", "Task 3.2") e o humano precisa de um arquivo para corrigir.
199
+ export const LABEL_DECOMPOSE = 'spec-wave:decompose';
200
+ export const LABEL_DECOMPOSE_APPLY = 'spec-wave:decompose-apply';
201
+
183
202
  // Labels de estado gravadas pelas automações (não são gatilhos do usuário).
184
203
  export const LABEL_CRITIQUE_FAILED = 'spec-wave:critique-failed';
185
204
  export const LABEL_DECOMPOSED = 'spec-wave:decomposed';
205
+ export const LABEL_DECOMPOSE_READY = 'spec-wave:decompose-ready';
206
+ export const LABEL_NEEDS_HUMAN = 'spec-wave:needs-human';
186
207
 
187
208
  export const TRIGGER_LABELS = [
188
209
  { name: 'spec-wave:spec', color: 'BFD4F2', description: 'Gerar spec.md via GitHub Action' },
189
210
  { name: 'spec-wave:plan', color: 'BFD4F2', description: 'Gerar plan.md via GitHub Action' },
190
211
  { name: 'spec-wave:ready', color: '0E8A16', description: 'Validar spec+plan e mover para Ready' },
191
212
  { name: 'spec-wave:plan-approved', color: '0E8A16', description: 'Spec+plan validados com sucesso' },
192
- { name: 'spec-wave:decompose', color: 'BFD4F2', description: 'Decompor em Stories e Tasks' },
213
+ { name: LABEL_DECOMPOSE, color: 'BFD4F2', description: 'Gerar/re-criticar o rascunho da decomposição (decomposition.md)' },
214
+ { name: LABEL_DECOMPOSE_APPLY, color: 'BFD4F2', description: 'Aplicar o decomposition.md revisado: criar Stories e Tasks' },
215
+ { name: LABEL_DECOMPOSE_READY, color: '0E8A16', description: 'Rascunho de decomposição pronto para revisão humana' },
193
216
  { name: LABEL_CRITIQUE_FAILED, color: 'B60205', description: 'Crítica adversarial apontou contradições graves' },
217
+ { name: LABEL_NEEDS_HUMAN, color: 'B60205', description: 'Crítica reprovou N vezes seguidas — precisa de revisão humana' },
194
218
  { name: LABEL_DECOMPOSED, color: 'EDEDED', description: 'Feature já decomposta em Stories e Tasks' },
195
219
  ];
196
220
 
197
221
  export const ALL_LABELS = [...TYPE_LABELS, ...PRIORITY_LABELS, ...TRIGGER_LABELS];
198
222
 
223
+ /**
224
+ * Nomes das labels de uma issue (função PURA).
225
+ *
226
+ * A REST devolve `labels` como array de objetos `{name}`, mas partes do fluxo
227
+ * (e os testes) passam strings — este helper aceita os dois, e também a issue
228
+ * inteira, porque a maioria dos chamadores já tem a issue em mão.
229
+ *
230
+ * @param {object|Array<string|{name:string}>} issueOrLabels issue ou array de labels
231
+ * @returns {string[]} nomes, sem entradas vazias
232
+ */
233
+ export function labelNames(issueOrLabels) {
234
+ const labels = Array.isArray(issueOrLabels) ? issueOrLabels : (issueOrLabels?.labels || []);
235
+ return labels.map(l => (typeof l === 'string' ? l : l?.name)).filter(Boolean);
236
+ }
237
+
199
238
  export const WORKFLOW_FILES = [
200
239
  'generate-plan.yml',
201
240
  'generate-spec.yml',
package/src/lib/board.mjs CHANGED
@@ -1,35 +1,29 @@
1
1
  // Helpers compartilhados de manipulação do board (GitHub Projects v2).
2
2
  // Extraídos de code-review.mjs/qa.mjs para uso também pelos comandos de CLI
3
3
  // (task/story/order). Ver a distinção Etapa × Status em config.mjs.
4
- import { existsSync, readFileSync } from 'node:fs';
5
- import path from 'node:path';
6
4
  import { addProjectItem, setItemSingleSelect, getSingleSelectField, getItemSingleSelectValue } from '../api/github-graphql.mjs';
7
- import { CONFIG_FILE, STAGE_ORDER } from '../config.mjs';
5
+ import { CONFIG_FILE, STAGE_ORDER, STATUS_OPTIONS } from '../config.mjs';
6
+ import { loadConfig } from './project-root.mjs';
8
7
 
9
8
  /**
10
- * Carrega o bloco `project` do .spec-wave.json do diretório atual.
9
+ * Carrega o bloco `project` do .spec-wave.json, procurando-o a partir de `cwd`
10
+ * e subindo na árvore (ver src/lib/project-root.mjs).
11
11
  *
12
12
  * @param {object} [opts]
13
- * @param {string} [opts.cwd=process.cwd()] diretório onde procurar o config
14
- * @returns {{ project: object|null, error: string|null }} project = bloco com
15
- * id/fields; error = motivo legível quando project é null (compõe os
16
- * avisos "… board não atualizado." dos chamadores).
13
+ * @param {string} [opts.cwd=process.cwd()] diretório onde começar a busca
14
+ * @returns {{ project: object|null, root: string|null, error: string|null }}
15
+ * project = bloco com id/fields; root = diretório do config;
16
+ * error = motivo legível quando project é null (compõe os avisos
17
+ * "… — board não atualizado." dos chamadores).
17
18
  */
18
19
  export function loadProjectConfig({ cwd = process.cwd() } = {}) {
19
- const configPath = path.join(cwd, CONFIG_FILE);
20
- if (!existsSync(configPath)) {
21
- return { project: null, error: `${CONFIG_FILE} não encontrado` };
22
- }
23
- let project;
24
- try {
25
- project = JSON.parse(readFileSync(configPath, 'utf-8')).project || {};
26
- } catch (err) {
27
- return { project: null, error: `${CONFIG_FILE} corrompido (${err.message})` };
28
- }
20
+ const { config, root, error } = loadConfig(cwd);
21
+ if (error) return { project: null, root, error };
22
+ const project = config.project || {};
29
23
  if (!project.id) {
30
- return { project: null, error: `Project não configurado em ${CONFIG_FILE}` };
24
+ return { project: null, root, error: `Project não configurado em ${CONFIG_FILE}` };
31
25
  }
32
- return { project, error: null };
26
+ return { project, root, error: null };
33
27
  }
34
28
 
35
29
  /**
@@ -50,6 +44,78 @@ export async function resolveField(token, project, name) {
50
44
  return await getSingleSelectField(token, project.id, name);
51
45
  }
52
46
 
47
+ /**
48
+ * A Etapa pode avançar de `current` para `target`? (função PURA — testável).
49
+ *
50
+ * Regra do board: a Etapa só AVANÇA. Três casos param o movimento:
51
+ * • já está no destino ou adiante na ordem canônica;
52
+ * • a etapa ATUAL não está em STAGE_ORDER (coluna criada à mão no board, ou
53
+ * resíduo de uma versão anterior do config). Antes esse caso caía no
54
+ * `curIdx === -1` e DESLIGAVA o guard, permitindo retrocesso — sem saber onde
55
+ * o item está na ordem, o certo é não mexer;
56
+ * • a etapa de DESTINO não está em STAGE_ORDER (nome inválido).
57
+ *
58
+ * @param {string|null} current etapa atual do item (null = ainda sem etapa)
59
+ * @param {string} target etapa de destino
60
+ * @returns {boolean} true se deve escrever a nova Etapa
61
+ */
62
+ export function shouldAdvanceStage(current, target) {
63
+ const tgtIdx = STAGE_ORDER.indexOf(target);
64
+ if (tgtIdx === -1) return false;
65
+ if (!current) return true; // item ainda sem Etapa definida
66
+ const curIdx = STAGE_ORDER.indexOf(current);
67
+ if (curIdx === -1) return false; // etapa atual desconhecida — não arrisca retroceder
68
+ return curIdx < tgtIdx;
69
+ }
70
+
71
+ // Normaliza para comparação: sem emoji/pontuação, minúsculo, espaços colapsados.
72
+ // "👀 Code Review" e "code review" precisam casar.
73
+ function stageKey(value) {
74
+ return String(value ?? '')
75
+ .normalize('NFD')
76
+ .replace(/[̀-ͯ]/g, '') // acentos (após NFD)
77
+ .replace(/[^\p{Letter}\p{Number}]+/gu, ' ') // emoji e pontuação
78
+ .trim()
79
+ .toLowerCase();
80
+ }
81
+
82
+ /**
83
+ * Resolve o nome canônico de uma Etapa a partir de entrada humana (função PURA).
84
+ *
85
+ * Aceita o nome com ou sem emoji, com qualquer caixa e sem acento
86
+ * ("code review", "Homologacao", "🚧 Desenvolvimento"). Casa primeiro exato,
87
+ * depois por prefixo; prefixo que casa com mais de uma etapa é AMBÍGUO e vira
88
+ * erro listando as candidatas — melhor recusar que mover para a coluna errada.
89
+ *
90
+ * @param {string} input entrada do usuário
91
+ * @returns {{ stage: string|null, error: string|null }}
92
+ */
93
+ export function resolveStageName(input) {
94
+ const key = stageKey(input);
95
+ const options = STATUS_OPTIONS.map(s => ({ name: s.name, key: stageKey(s.name) }));
96
+ const list = () => STATUS_OPTIONS.map(s => `"${s.name}"`).join(', ');
97
+
98
+ if (!key) {
99
+ return { stage: null, error: `Etapa não informada. Use uma destas: ${list()}.` };
100
+ }
101
+
102
+ const exact = options.find(o => o.key === key);
103
+ if (exact) return { stage: exact.name, error: null };
104
+
105
+ const partial = options.filter(o => o.key.startsWith(key));
106
+ if (partial.length === 1) return { stage: partial[0].name, error: null };
107
+ if (partial.length > 1) {
108
+ return {
109
+ stage: null,
110
+ error:
111
+ `Etapa "${input}" é ambígua — casa com ${partial.map(o => `"${o.name}"`).join(' e ')}. ` +
112
+ 'Seja mais específico.',
113
+ };
114
+ }
115
+
116
+ return { stage: null, error: `Etapa "${input}" não existe. Use uma destas: ${list()}.` };
117
+ }
118
+
53
119
  /**
54
120
  * Avança um item do board para `targetStage` (Etapa) e define o Status para
55
121
  * `targetStatus`. Uma issue só AVANÇA: se já estiver em `targetStage` ou em uma
@@ -68,13 +134,9 @@ export async function advanceToStage(token, project, etapaField, statusField, no
68
134
  const itemId = await addProjectItem(token, project.id, nodeId);
69
135
 
70
136
  if (etapaField?.id && targetStage) {
71
- // Nunca retroceder: compara a etapa atual com a de destino na ordem canônica.
137
+ // Nunca retroceder a decisão vive em shouldAdvanceStage (pura, testada).
72
138
  const current = await getItemSingleSelectValue(token, itemId, etapaField.id).catch(() => null);
73
- const curIdx = current ? STAGE_ORDER.indexOf(current) : -1;
74
- const tgtIdx = STAGE_ORDER.indexOf(targetStage);
75
- if (curIdx !== -1 && tgtIdx !== -1 && curIdx >= tgtIdx) {
76
- return false; // já está nessa etapa ou adiante — não retrocede
77
- }
139
+ if (!shouldAdvanceStage(current, targetStage)) return false;
78
140
  const optionId = etapaField.options?.[targetStage];
79
141
  if (optionId) await setItemSingleSelect(token, project.id, itemId, etapaField.id, optionId);
80
142
  }