@spec-wave/cli 0.13.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.
- package/README.md +39 -6
- package/bin/spec-wave.mjs +14 -1
- package/package.json +1 -1
- package/src/commands/code-review.mjs +5 -8
- package/src/commands/decompose.mjs +412 -130
- package/src/commands/dev-agent.mjs +3 -2
- package/src/commands/doctor.mjs +239 -9
- package/src/commands/generate-plan.mjs +105 -30
- package/src/commands/generate-spec.mjs +17 -5
- package/src/commands/implement.mjs +39 -15
- package/src/commands/info.mjs +4 -3
- package/src/commands/issue.mjs +4 -4
- package/src/commands/move.mjs +162 -0
- package/src/commands/order.mjs +1 -12
- package/src/commands/qa.mjs +5 -8
- package/src/commands/refresh.mjs +4 -3
- package/src/commands/story.mjs +1 -12
- package/src/commands/task.mjs +1 -11
- package/src/commands/update.mjs +43 -19
- package/src/commands/validate.mjs +37 -22
- package/src/config.mjs +40 -1
- package/src/lib/board.mjs +88 -26
- package/src/lib/claude.mjs +315 -70
- package/src/lib/critique.mjs +391 -91
- package/src/lib/decomposition-doc.mjs +451 -0
- package/src/lib/implement-board.mjs +14 -1
- package/src/lib/project-root.mjs +93 -0
- package/src/lib/templates.mjs +53 -0
- package/src/setup/files.mjs +3 -10
- package/src/templates/skill/SKILL.md +143 -27
- package/src/templates/workflows/code-review.yml +1 -1
- package/src/templates/workflows/decompose.yml +20 -6
- package/src/templates/workflows/generate-plan.yml +1 -1
- package/src/templates/workflows/generate-spec.yml +1 -1
- package/src/templates/workflows/qa.yml +1 -1
- 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 {
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
34
|
-
const critiqueFailed =
|
|
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 =
|
|
55
|
+
const planPath = path.join(featureDir, 'plan.md');
|
|
44
56
|
if (!existsSync(planPath)) {
|
|
45
|
-
errors.push('❌ `plan.md` não encontrado em `' +
|
|
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 =
|
|
71
|
+
const specPath = path.join(featureDir, 'spec.md');
|
|
60
72
|
if (!existsSync(specPath)) {
|
|
61
|
-
errors.push('❌ `spec.md` não encontrado em `' +
|
|
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
|
|
87
|
-
// estão estruturalmente válidos — só
|
|
88
|
-
// caso NÃO devolvemos a feature para a
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
`- [\`${
|
|
105
|
-
`- [\`${
|
|
106
|
-
`A Feature está pronta para decomposição.
|
|
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:
|
|
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
|
|
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
|
|
14
|
-
* @returns {{ project: object|null, error: string|null }}
|
|
15
|
-
* id/fields;
|
|
16
|
-
*
|
|
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
|
|
20
|
-
if (
|
|
21
|
-
|
|
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
|
|
137
|
+
// Nunca retroceder — a decisão vive em shouldAdvanceStage (pura, testada).
|
|
72
138
|
const current = await getItemSingleSelectValue(token, itemId, etapaField.id).catch(() => null);
|
|
73
|
-
|
|
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
|
}
|