@spec-wave/cli 0.12.0 → 0.14.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 (40) hide show
  1. package/README.md +39 -27
  2. package/bin/spec-wave.mjs +14 -4
  3. package/package.json +1 -1
  4. package/src/api/github-graphql.mjs +0 -4
  5. package/src/api/github-rest.mjs +0 -13
  6. package/src/commands/code-review.mjs +5 -8
  7. package/src/commands/decompose.mjs +410 -251
  8. package/src/commands/dev-agent.mjs +3 -2
  9. package/src/commands/doctor.mjs +239 -9
  10. package/src/commands/generate-plan.mjs +111 -51
  11. package/src/commands/generate-spec.mjs +20 -22
  12. package/src/commands/implement.mjs +46 -24
  13. package/src/commands/info.mjs +4 -3
  14. package/src/commands/issue.mjs +4 -4
  15. package/src/commands/move.mjs +162 -0
  16. package/src/commands/order.mjs +1 -12
  17. package/src/commands/qa.mjs +5 -8
  18. package/src/commands/refresh.mjs +4 -3
  19. package/src/commands/story.mjs +1 -12
  20. package/src/commands/task.mjs +1 -11
  21. package/src/commands/update.mjs +43 -19
  22. package/src/commands/validate.mjs +47 -35
  23. package/src/config.mjs +40 -6
  24. package/src/lib/board.mjs +88 -26
  25. package/src/lib/claude.mjs +315 -70
  26. package/src/lib/critique.mjs +391 -91
  27. package/src/lib/decomposition-doc.mjs +451 -0
  28. package/src/lib/implement-board.mjs +14 -1
  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 +137 -61
  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
  39. package/src/lib/feature-docs.mjs +0 -89
  40. package/src/lib/force.mjs +0 -34
@@ -3,76 +3,85 @@ 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 { resolveDoc } from '../lib/feature-docs.mjs';
7
- 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';
8
10
  import { findIncompleteDocSigns } from '../lib/doc-completeness.mjs';
11
+ import { loadConfig } from '../lib/project-root.mjs';
9
12
 
10
13
  export async function validate({ issueNumber }) {
11
14
  const token = await resolveToken();
12
15
  const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
13
- const configPath = path.join(process.cwd(), CONFIG_FILE);
14
- let cfg = {};
15
- try { if (existsSync(configPath)) cfg = JSON.parse(readFileSync(configPath, 'utf-8')); } catch {}
16
- const owner = envOwner || cfg.owner;
17
- const repo = envRepo || cfg.repo;
16
+ const { config: cfg, root } = loadConfig();
17
+ const owner = envOwner || cfg?.owner;
18
+ const repo = envRepo || cfg?.repo;
18
19
 
19
20
  if (!owner || !repo) {
20
21
  throw new Error(
21
22
  'Não foi possível determinar owner/repo.\n' +
22
- '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}.`
23
24
  );
24
25
  }
25
26
 
26
27
  const issue = await getIssue(token, owner, repo, parseInt(issueNumber, 10));
27
28
  const slug = slugify(issue.title);
28
- 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);
29
33
 
30
34
  const errors = [];
31
35
 
32
36
  // Bloqueio da crítica adversarial: enquanto a label critique-failed estiver
33
37
  // na issue, o ready não é liberado — a correção dos documentos é manual.
34
- const labelNames = (issue.labels || []).map((l) => (typeof l === 'string' ? l : l.name));
35
- 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);
36
41
  if (critiqueFailed) {
37
42
  errors.push(
38
43
  '🔎 A crítica adversarial apontou contradições GRAVES (veja o comentário na issue). ' +
39
44
  `Corrija os documentos e remova a label \`${LABEL_CRITIQUE_FAILED}\` para liberar o ready.`
40
45
  );
41
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
+ }
42
53
 
43
- // Valida sempre o documento ATUAL de cada tipo — com regerações forçadas a
44
- // versão vigente é a maior (plan-v2.md, plan-v3.md), não o plan.md original.
45
- const plan = resolveDoc(featureDir, 'plan');
46
- const planPath = plan?.path || `${featureDir}/plan.md`;
47
- if (!plan) {
48
- errors.push('❌ `plan.md` não encontrado em `' + planPath + '`');
54
+ // Check plan.md
55
+ const planPath = path.join(featureDir, 'plan.md');
56
+ if (!existsSync(planPath)) {
57
+ errors.push('❌ `plan.md` não encontrado em `' + `${featureRel}/plan.md` + '`');
49
58
  } else {
50
- const planContent = readFileSync(plan.path, 'utf-8');
59
+ const planContent = readFileSync(planPath, 'utf-8');
51
60
  for (const section of REQUIRED_PLAN_SECTIONS) {
52
61
  if (!planContent.includes(`# ${section}`)) {
53
- errors.push(`❌ Seção obrigatória ausente no plano (v${plan.version}): **${section}**`);
62
+ errors.push(`❌ Seção obrigatória ausente no plan.md: **${section}**`);
54
63
  }
55
64
  }
56
65
  for (const problem of findIncompleteDocSigns(planContent)) {
57
- errors.push(`❌ \`${planPath}\` parece incompleto: ${problem}`);
66
+ errors.push(`❌ \`plan.md\` parece incompleto: ${problem}`);
58
67
  }
59
68
  }
60
69
 
61
- const spec = resolveDoc(featureDir, 'spec');
62
- const specPath = spec?.path || `${featureDir}/spec.md`;
63
- if (!spec) {
64
- errors.push('❌ `spec.md` não encontrado em `' + specPath + '`');
70
+ // Check spec.md
71
+ const specPath = path.join(featureDir, 'spec.md');
72
+ if (!existsSync(specPath)) {
73
+ errors.push('❌ `spec.md` não encontrado em `' + `${featureRel}/spec.md` + '`');
65
74
  } else {
66
- const specContent = readFileSync(spec.path, 'utf-8');
75
+ const specContent = readFileSync(specPath, 'utf-8');
67
76
  for (const section of REQUIRED_SPEC_SECTIONS) {
68
77
  if (!specContent.includes(`# ${section}`)) {
69
- errors.push(`❌ Seção obrigatória ausente na spec (v${spec.version}): **${section}**`);
78
+ errors.push(`❌ Seção obrigatória ausente no spec.md: **${section}**`);
70
79
  }
71
80
  }
72
81
  // Seções presentes não garantem documento completo: um corte dentro da
73
82
  // última seção passa na checagem acima (foi o caso da EP2-F13).
74
83
  for (const problem of findIncompleteDocSigns(specContent)) {
75
- errors.push(`❌ \`${specPath}\` parece incompleto: ${problem}`);
84
+ errors.push(`❌ \`spec.md\` parece incompleto: ${problem}`);
76
85
  }
77
86
  }
78
87
 
@@ -86,11 +95,13 @@ export async function validate({ issueNumber }) {
86
95
  errors.join('\n') +
87
96
  `\n\nCorreija os problemas e adicione novamente a label \`spec-wave:ready\`.`
88
97
  );
89
- // Quando a ÚNICA falha é a da crítica adversarial, os documentos existem e
90
- // estão estruturalmente válidos — só precisam de correção manual. Nesse
91
- // caso NÃO devolvemos a feature para a etapa de spec (spec-wave:spec).
92
- const onlyCritiqueFailed = critiqueFailed && errors.length === 1;
93
- 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) {
94
105
  // Send back to spec stage
95
106
  await addLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:spec');
96
107
  }
@@ -104,9 +115,10 @@ export async function validate({ issueNumber }) {
104
115
  await commentOnIssue(
105
116
  token, owner, repo, parseInt(issueNumber, 10),
106
117
  `✅ **Validação concluída com sucesso!**\n\n` +
107
- `- [\`${specPath}\`](${specPath}) ✓\n` +
108
- `- [\`${planPath}\`](${planPath}) ✓\n\n` +
109
- `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` +
110
122
  `\`\`\`\ngh issue edit ${issueNumber} --add-label "spec-wave:decompose"\n\`\`\``
111
123
  );
112
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,27 +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';
186
- // Modificador (não é gatilho — sozinha não dispara workflow nenhum): quando
187
- // presente junto de uma label de gatilho, manda o comando re-executar a etapa
188
- // ignorando os guards. É consumida (removida) pelo run que a leu.
189
- export const LABEL_FORCE = 'spec-wave:force';
205
+ export const LABEL_DECOMPOSE_READY = 'spec-wave:decompose-ready';
206
+ export const LABEL_NEEDS_HUMAN = 'spec-wave:needs-human';
190
207
 
191
208
  export const TRIGGER_LABELS = [
192
209
  { name: 'spec-wave:spec', color: 'BFD4F2', description: 'Gerar spec.md via GitHub Action' },
193
210
  { name: 'spec-wave:plan', color: 'BFD4F2', description: 'Gerar plan.md via GitHub Action' },
194
211
  { name: 'spec-wave:ready', color: '0E8A16', description: 'Validar spec+plan e mover para Ready' },
195
212
  { name: 'spec-wave:plan-approved', color: '0E8A16', description: 'Spec+plan validados com sucesso' },
196
- { 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' },
197
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' },
198
218
  { name: LABEL_DECOMPOSED, color: 'EDEDED', description: 'Feature já decomposta em Stories e Tasks' },
199
- { name: LABEL_FORCE, color: 'D93F0B', description: 'Re-executa a etapa ignorando os guards (consumida no run)' },
200
219
  ];
201
220
 
202
221
  export const ALL_LABELS = [...TYPE_LABELS, ...PRIORITY_LABELS, ...TRIGGER_LABELS];
203
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
+
204
238
  export const WORKFLOW_FILES = [
205
239
  'generate-plan.yml',
206
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
  }