@spec-wave/cli 0.24.0 → 0.26.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.
@@ -0,0 +1,180 @@
1
+ // Alterna entre rodar o fluxo no GitHub Actions e rodar nesta máquina.
2
+ //
3
+ // O comando escreve os DOIS lados do interruptor — o `.spec-wave.json`, que a
4
+ // CLI e a skill leem, e a variável de repositório, que o `if:` de cada job
5
+ // avalia. Escrever só um deles é o defeito que o comando existe para evitar:
6
+ // config em "local" com a variável ausente significa workflow disparando e
7
+ // minuto sendo cobrado enquanto o usuário acha que desligou.
8
+ //
9
+ // A variável exige permissão de administração. Sem ela o comando NÃO finge que
10
+ // deu certo: grava o config, diz o que falta e aponta a alternativa manual.
11
+
12
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
13
+ import path from 'node:path';
14
+
15
+ import * as p from '@clack/prompts';
16
+ import chalk from 'chalk';
17
+
18
+ import { resolveToken } from '../api/auth.mjs';
19
+ import { getRepoVariable, setRepoVariable, deleteRepoVariable } from '../api/github-rest.mjs';
20
+ import { CONFIG_FILE, WORKFLOW_FILES } from '../config.mjs';
21
+ import { updateConfig } from '../lib/config-file.mjs';
22
+ import {
23
+ EXECUTION_MODES, EXECUTION_VARIABLE, EXECUTION_GUARD,
24
+ configuredMode, variableValueFor, describeModeState, shouldWriteVariable,
25
+ } from '../lib/execution-mode.mjs';
26
+ import { loadConfig } from '../lib/project-root.mjs';
27
+
28
+ /**
29
+ * Workflows instalados que NÃO carregam a guarda (função quase pura — só lê o fs).
30
+ *
31
+ * @param {string|null} root
32
+ * @returns {string[]}
33
+ */
34
+ export function unguardedWorkflows(root) {
35
+ const dir = path.join(root || process.cwd(), '.github', 'workflows');
36
+ if (!existsSync(dir)) return [];
37
+ const presentes = new Set(readdirSync(dir));
38
+ return WORKFLOW_FILES
39
+ .filter(file => presentes.has(file))
40
+ .filter(file => !readFileSync(path.join(dir, file), 'utf-8').includes(EXECUTION_GUARD));
41
+ }
42
+
43
+ // A variável só é legível por quem administra o repo. 403 não é falha: é "não
44
+ // verificável", e quem chama distingue isso de "não existe" (null).
45
+ async function readVariable(token, owner, repo) {
46
+ try {
47
+ return await getRepoVariable(token, owner, repo, EXECUTION_VARIABLE);
48
+ } catch (err) {
49
+ if (err.status === 403 || err.status === 404) return undefined;
50
+ throw err;
51
+ }
52
+ }
53
+
54
+ export async function mode({ target, dryRun = false } = {}) {
55
+ p.intro(chalk.bold('spec-wave mode'));
56
+
57
+ const { config, root, configPath } = loadConfig();
58
+ if (!config) {
59
+ p.log.error(`${CONFIG_FILE} não encontrado — rode \`npx @spec-wave/cli@latest init\` antes.`);
60
+ process.exit(1);
61
+ }
62
+
63
+ const alvo = target ? String(target).toLowerCase() : null;
64
+ if (alvo && !EXECUTION_MODES.includes(alvo)) {
65
+ p.log.error(`Modo inválido: ${alvo}. Use um de: ${EXECUTION_MODES.join(', ')}.`);
66
+ process.exit(1);
67
+ }
68
+
69
+ const atual = configuredMode(config);
70
+ const owner = config.owner;
71
+ const repo = config.repo;
72
+
73
+ let token = null;
74
+ let variavel;
75
+ if (owner && repo) {
76
+ try {
77
+ token = await resolveToken();
78
+ variavel = await readVariable(token, owner, repo);
79
+ } catch (err) {
80
+ p.log.warn(`Não foi possível consultar a variável do repositório: ${err.message}`);
81
+ variavel = undefined;
82
+ }
83
+ }
84
+
85
+ // Sem argumento: só relatório.
86
+ if (!alvo) {
87
+ const estado = describeModeState({
88
+ configured: atual, variable: variavel, unguardedWorkflows: unguardedWorkflows(root),
89
+ });
90
+ p.log.info(estado.summary);
91
+ for (const nota of estado.notes) p.log.message(`• ${nota}`);
92
+ for (const fix of estado.fixes) p.log.warn(fix);
93
+ p.outro(
94
+ atual === 'local'
95
+ ? `Próximo passo de uma issue: ${chalk.cyan('spec-wave run <issue>')}`
96
+ : `Para desligar o CI: ${chalk.cyan('spec-wave mode local')}`
97
+ );
98
+ return { mode: atual, variable: variavel, changed: false };
99
+ }
100
+
101
+ const esperado = variableValueFor(alvo);
102
+ const mudaConfig = atual !== alvo;
103
+ // `undefined` é "não deu para LER" (403 — a variável exige admin), e não
104
+ // "está como deveria". Tratar os dois iguais fazia o comando pular a escrita
105
+ // e ainda assim anunciar que config e variável coincidiam: o usuário saía
106
+ // achando que desligou o CI, com os workflows armados e o minuto sendo
107
+ // cobrado — exatamente o meio-caminho que este comando existe para evitar.
108
+ //
109
+ // Não conseguir ler quase sempre significa não conseguir escrever. Tentar e
110
+ // falhar com a mensagem certa é honesto; não tentar e dizer "coincidem" não.
111
+ const mudaVariavel = shouldWriteVariable({ variable: variavel, expected: esperado });
112
+
113
+ if (!mudaConfig && !mudaVariavel) {
114
+ p.log.success(`Já está em ${chalk.bold(alvo)} — config e variável do repositório coincidem.`);
115
+ p.outro('Nada a fazer.');
116
+ return { mode: alvo, variable: variavel, changed: false, variableApplied: true };
117
+ }
118
+
119
+ if (dryRun) {
120
+ if (mudaConfig) p.log.info(`${CONFIG_FILE}: execution.mode ${atual} → ${alvo}`);
121
+ if (mudaVariavel) {
122
+ const atualDaVariavel = variavel === undefined
123
+ ? 'valor atual desconhecido — sem permissão para ler'
124
+ : `valor atual: ${variavel === null ? 'ausente' : variavel}`;
125
+ p.log.info(esperado === null
126
+ ? `Variável ${EXECUTION_VARIABLE}: remover (${atualDaVariavel})`
127
+ : `Variável ${EXECUTION_VARIABLE}: definir como "${esperado}" (${atualDaVariavel})`);
128
+ }
129
+ p.outro('Dry-run: nada foi alterado.');
130
+ return { mode: atual, variable: variavel, changed: false };
131
+ }
132
+
133
+ if (mudaConfig) {
134
+ const { changed } = updateConfig(cfg => {
135
+ cfg.execution = { ...(cfg.execution || {}), mode: alvo };
136
+ }, { cwd: root || process.cwd() });
137
+ if (changed) p.log.success(`${CONFIG_FILE} atualizado: execution.mode = ${alvo}`);
138
+ }
139
+
140
+ let variavelOk = true;
141
+ if (token && owner && repo) {
142
+ try {
143
+ if (esperado === null) {
144
+ const removida = await deleteRepoVariable(token, owner, repo, EXECUTION_VARIABLE);
145
+ p.log.success(removida
146
+ ? `Variável ${EXECUTION_VARIABLE} removida — os workflows voltam a disparar.`
147
+ : `Variável ${EXECUTION_VARIABLE} já não existia.`);
148
+ } else {
149
+ await setRepoVariable(token, owner, repo, EXECUTION_VARIABLE, esperado);
150
+ p.log.success(`Variável ${EXECUTION_VARIABLE}=${esperado} — os jobs passam a ser pulados (0 minutos).`);
151
+ }
152
+ } catch (err) {
153
+ variavelOk = false;
154
+ p.log.error(
155
+ `Não foi possível escrever a variável ${EXECUTION_VARIABLE} (${err.status || ''} ${err.message}).\n` +
156
+ 'Ela exige administração no repositório — peça a um admin, ou defina em ' +
157
+ 'Settings → Secrets and variables → Actions → Variables.'
158
+ );
159
+ }
160
+ } else {
161
+ variavelOk = false;
162
+ p.log.warn(`Sem owner/repo ou token: só o ${CONFIG_FILE} foi atualizado.`);
163
+ }
164
+
165
+ const semGuarda = unguardedWorkflows(root);
166
+ if (alvo === 'local' && semGuarda.length > 0) {
167
+ p.log.warn(
168
+ `Estes workflows instalados ainda não têm a guarda \`${EXECUTION_GUARD}\` e vão rodar mesmo assim: ` +
169
+ `${semGuarda.join(', ')}. Rode \`npx @spec-wave/cli@latest update\`.`
170
+ );
171
+ }
172
+
173
+ p.log.message(chalk.dim(`Commite o ${configPath ? path.basename(configPath) : CONFIG_FILE} — quem clona o repo lê a versão versionada.`));
174
+ p.outro(
175
+ alvo === 'local'
176
+ ? `Modo local. Conduza o fluxo com ${chalk.cyan('spec-wave run <issue>')}.`
177
+ : 'Modo actions. As labels de gatilho voltam a disparar os workflows.'
178
+ );
179
+ return { mode: alvo, variable: esperado, changed: true, variableApplied: variavelOk };
180
+ }
@@ -0,0 +1,491 @@
1
+ // Executa LOCALMENTE o passo que a label dispararia no GitHub Actions.
2
+ //
3
+ // O YAML nunca fez trabalho nenhum: ele instala a CLI e chama um comando. Este
4
+ // comando fecha o buraco que sobra quando os workflows estão desarmados
5
+ // (`spec-wave mode local`) — decidir QUAL comando é o próximo e chamá-lo, sem
6
+ // aplicar label nenhuma.
7
+ //
8
+ // A decisão é uma função pura (`lib/next-step.mjs`), testada sem rede. Aqui só
9
+ // mora o que é impuro: coletar o estado, segurar o lock, despachar e relatar.
10
+ //
11
+ // Três coisas que este comando NUNCA faz, e o porquê:
12
+ // • aplicar label de gatilho — dispararia o Action e a execução aconteceria duas vezes;
13
+ // • rodar dentro do Actions — lá o contrato é a label (`assertNotInActions`);
14
+ // • encadear passos sem teto — cada passo de IA custa dinheiro (`--max-steps`).
15
+
16
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs';
17
+ import { execSync } from 'node:child_process';
18
+ import path from 'node:path';
19
+
20
+ import chalk from 'chalk';
21
+
22
+ import { resolveToken } from '../api/auth.mjs';
23
+ import { getIssue, getPR, getFileContent, listPullRequestReviews } from '../api/github-rest.mjs';
24
+ import { loadProjectConfig } from '../lib/board.mjs';
25
+ import { existsOnRemote } from '../lib/doc-availability.mjs';
26
+ import { featureDocPaths, bugDocPaths } from '../lib/doc-paths.mjs';
27
+ import { isActionsRun, resolveFlowContext } from '../lib/flow-run.mjs';
28
+ import { detectIssueType } from '../lib/issue-type.mjs';
29
+ import { nextStep, resolveForcedStep, stepDocs, docsForType, STEPS } from '../lib/next-step.mjs';
30
+ import { nextPrStep, reviewVerdict } from '../lib/pr-step.mjs';
31
+ import { configuredMode } from '../lib/execution-mode.mjs';
32
+ import { labelNames } from '../config.mjs';
33
+
34
+ const LOCK_STALE_MS = 30 * 60 * 1000;
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Lock — o substituto do `concurrency: spec-wave-item-<n>` dos workflows.
38
+ //
39
+ // Sem ele, dois `run` na mesma issue geram dois documentos, dois commits
40
+ // disputando a ponta da branch e leitura-modificação-escrita concorrente do
41
+ // comentário de uso. Fica em .git/ porque já é ignorado e é por clone.
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /**
45
+ * Diretório .git COMPARTILHADO do clone (função com I/O, isolada para teste).
46
+ *
47
+ * Montar `<root>/.git` à mão assume que `.git` é um diretório — e num git
48
+ * worktree ele é um ARQUIVO com `gitdir: <caminho real>`. O `mkdirSync` do
49
+ * acquireLock estourava ENOTDIR ali, derrubando TODO `spec-wave run` de dentro
50
+ * de um worktree, inclusive `--dry-run`, antes de qualquer trabalho.
51
+ *
52
+ * `--git-common-dir` e não `--git-dir`: num worktree o `--git-dir` é
53
+ * `.git/worktrees/<nome>`, o que tornaria o lock por WORKTREE. Dois `run` na
54
+ * mesma issue em worktrees diferentes rodariam em paralelo commitando na mesma
55
+ * branch — exatamente o que este lock existe para impedir. O comum preserva o
56
+ * "é por clone" que o comentário acima declara.
57
+ *
58
+ * O caminho devolvido é RELATIVO ao cwd quando se está no clone principal
59
+ * (`.git` na raiz, `../../.git` num subdiretório) e absoluto de dentro de um
60
+ * worktree — daí o `path.resolve`. Sem ele, rodar de um subdiretório criaria um
61
+ * `.git/` novo ali dentro, que é um estrago pior e mais silencioso que o ENOTDIR.
62
+ *
63
+ * @param {string} [root] raiz do projeto (a do .spec-wave.json)
64
+ * @returns {string} caminho absoluto do diretório .git compartilhado
65
+ */
66
+ export function gitCommonDir(root) {
67
+ const cwd = root || process.cwd();
68
+ try {
69
+ const out = execSync('git rev-parse --git-common-dir', {
70
+ cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'],
71
+ }).trim();
72
+ if (out) return path.resolve(cwd, out);
73
+ } catch {
74
+ // Fora de um repositório git: cai no palpite antigo. O `run` vai falhar
75
+ // adiante de qualquer forma (ele commita e faz push), e com mensagem melhor.
76
+ }
77
+ return path.join(cwd, '.git');
78
+ }
79
+
80
+ export function lockPath(root, key) {
81
+ return path.join(gitCommonDir(root), 'spec-wave', `run-${key}.lock`);
82
+ }
83
+
84
+ function acquireLock(root, key) {
85
+ const file = lockPath(root, key);
86
+ mkdirSync(path.dirname(file), { recursive: true });
87
+ const payload = JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() });
88
+ try {
89
+ writeFileSync(file, payload, { flag: 'wx' });
90
+ return file;
91
+ } catch (err) {
92
+ if (err.code !== 'EEXIST') throw err;
93
+ let idade = Infinity;
94
+ let dono = null;
95
+ try {
96
+ dono = JSON.parse(readFileSync(file, 'utf-8'));
97
+ idade = Date.now() - Date.parse(dono.startedAt);
98
+ } catch { /* lock ilegível conta como velho */ }
99
+ if (idade < LOCK_STALE_MS) {
100
+ throw new Error(
101
+ `Já existe um \`spec-wave run\` para ${key} (pid ${dono?.pid ?? '?'}, desde ${dono?.startedAt ?? '?'}).\n` +
102
+ `Se tiver certeza de que morreu, apague ${file}.`
103
+ );
104
+ }
105
+ console.warn(`⚠️ Lock antigo encontrado (${file}) — assumindo processo morto e seguindo.`);
106
+ writeFileSync(file, payload);
107
+ return file;
108
+ }
109
+ }
110
+
111
+ function releaseLock(file) {
112
+ try {
113
+ if (file) unlinkSync(file);
114
+ } catch { /* já removido */ }
115
+ }
116
+
117
+ // Commits locais ainda não publicados: gerar o próximo documento por cima de um
118
+ // anterior que ficou só no clone é execução parcial disfarçada de sucesso.
119
+ function unpushedCommits(root) {
120
+ try {
121
+ const out = execSync('git rev-list --count @{u}..HEAD', {
122
+ cwd: root || process.cwd(), encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'],
123
+ }).trim();
124
+ return Number.parseInt(out, 10) || 0;
125
+ } catch {
126
+ return 0; // sem upstream configurado: não dá para afirmar nada
127
+ }
128
+ }
129
+
130
+ function assertNotInActions() {
131
+ if (!isActionsRun()) return;
132
+ throw new Error(
133
+ 'O `run` é o gatilho do modo LOCAL — dentro do GitHub Actions o gatilho é a label, ' +
134
+ 'e o workflow já chama o comando certo. Rodar os dois seria executar o passo duas vezes.'
135
+ );
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Coleta do estado
140
+ // ---------------------------------------------------------------------------
141
+
142
+ function localDocStates(issue, type, root) {
143
+ if (type === 'Bug') {
144
+ const { fileRel, fileAbs } = bugDocPaths(issue.title, root);
145
+ return {
146
+ docs: { bug: existsSync(fileAbs) ? 'local' : 'missing' },
147
+ docPaths: { bug: fileRel },
148
+ };
149
+ }
150
+ const paths = featureDocPaths(root, issue, type);
151
+ const docs = {};
152
+ const docPaths = {};
153
+ for (const nome of ['spec', 'plan', 'decomposition']) {
154
+ docs[nome] = existsSync(paths[nome].abs) ? 'local' : 'missing';
155
+ docPaths[nome] = paths[nome].rel;
156
+ }
157
+ return { docs, docPaths };
158
+ }
159
+
160
+ /**
161
+ * Sonda o remoto SÓ pelos documentos que o passo escolhido usa e que faltam no
162
+ * clone. É a diferença entre "ainda não foi gerado" e "foi gerado e você não
163
+ * puxou" — a segunda leva a sobrescrever trabalho publicado.
164
+ */
165
+ /**
166
+ * Documentos que vale sondar no remoto antes de acreditar na decisão (PURA).
167
+ *
168
+ * O G5 ("as labels afirmam um documento que não existe aqui") diz, com todas as
169
+ * letras, que o arquivo *não está no clone nem no remoto* — e manda renomear o
170
+ * diretório ou REGERAR. Enquanto a sonda só rodava para decisão não-bloqueada,
171
+ * essa frase era afirmada sem nunca ter perguntado ao remoto: num clone que
172
+ * está apenas atrás, o conselho levava a regerar por cima de um documento
173
+ * publicado, gastando uma chamada de IA para destruir o artefato bom. A
174
+ * correção certa era `git pull`.
175
+ *
176
+ * Nesse caso o passo é `none` e não há `writes`/`reads` de onde tirar a lista —
177
+ * sondamos os documentos do tipo.
178
+ *
179
+ * @param {{action: string, blocked: object|null}} decision
180
+ * @param {string|null} type
181
+ * @returns {string[]}
182
+ */
183
+ export function docsToProbe(decision, type) {
184
+ if (decision.blocked?.code === 'inconsistent-state') return docsForType(type);
185
+ if (decision.blocked || !STEPS[decision.action]) return [];
186
+ // stepDocs e não `[writes, ...reads]`: `validate` e `decompose` leem
187
+ // documentos diferentes conforme o tipo, e sondar a lista errada devolve um
188
+ // `docs` que o G6 aprova por engano.
189
+ return stepDocs(decision.action, type);
190
+ }
191
+
192
+ async function probeRemote({ alvos, docs, docPaths, token, owner, repo }) {
193
+ if (alvos.length === 0) return docs;
194
+
195
+ const atualizado = { ...docs };
196
+ for (const doc of alvos) {
197
+ const onRemote = await existsOnRemote({
198
+ getFileContent, token, owner, repo, pathRel: docPaths[doc],
199
+ });
200
+ if (onRemote === true) atualizado[doc] = 'remote';
201
+ else if (onRemote === null) atualizado[doc] = 'unknown';
202
+ }
203
+ return atualizado;
204
+ }
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // Relato
208
+ // ---------------------------------------------------------------------------
209
+
210
+ function reportDecision(decision, { issueNumber, type, title, warnings }) {
211
+ console.log(chalk.bold(`\nIssue #${issueNumber} · ${type || 'tipo desconhecido'} · ${title}`));
212
+ console.log(`Próximo passo: ${chalk.cyan(decision.action)}`);
213
+ console.log(`Motivo: ${decision.reason}`);
214
+ if (decision.command) console.log(`Comando: ${chalk.dim(decision.command)}`);
215
+ for (const aviso of warnings) console.warn(chalk.yellow(`⚠️ ${aviso}`));
216
+ if (decision.blocked) {
217
+ console.log(chalk.yellow(`\n⛔ ${decision.blocked.code}: ${decision.blocked.message}`));
218
+ console.log(` ${decision.blocked.unblock}`);
219
+ }
220
+ }
221
+
222
+ // O prefixo do título é o que os workflows filtram (`contains(title, '[FEATURE]')`),
223
+ // enquanto detectIssueType tem fallback por label: sem o aviso, uma issue rodaria
224
+ // local e nunca rodaria no runner, e a diferença só apareceria ao voltar o modo.
225
+ function parityWarnings(issue, type) {
226
+ const avisos = [];
227
+ const titulo = String(issue.title || '');
228
+ if (type && !titulo.includes(`[${type.toUpperCase()}]`)) {
229
+ avisos.push(
230
+ `O título não traz o prefixo [${type.toUpperCase()}] — o workflow correspondente NÃO ` +
231
+ 'dispararia para esta issue no modo actions (ele filtra pelo título).'
232
+ );
233
+ }
234
+ return avisos;
235
+ }
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // Despacho
239
+ // ---------------------------------------------------------------------------
240
+
241
+ /**
242
+ * Saída de `--json` (função PURA — devolve o texto, não imprime).
243
+ *
244
+ * UM documento, sempre. A impressão morava dentro do laço de passos, então
245
+ * `--max-steps N` emitia N documentos JSON concatenados — saída que nenhum
246
+ * parser aceita, numa flag que a skill anuncia justamente para ramificar
247
+ * programaticamente.
248
+ *
249
+ * O desfecho fica no TOPO, e não só dentro de `steps`: é o que quase todo
250
+ * consumidor lê, e é exatamente o formato que a execução de um passo só já
251
+ * produzia. Assim a correção não quebra quem já lia `.action`.
252
+ *
253
+ * @param {object} params
254
+ * @param {string|number} params.issueNumber
255
+ * @param {string|null} params.type
256
+ * @param {object|null} params.ultimaDecisao
257
+ * @param {object[]} [params.passos]
258
+ * @returns {string} JSON indentado
259
+ */
260
+ export function renderRunJson({ issueNumber, type, ultimaDecisao, passos = [] }) {
261
+ return JSON.stringify(
262
+ { issue: Number(issueNumber), type: type ?? null, ...ultimaDecisao, steps: passos },
263
+ null, 2,
264
+ );
265
+ }
266
+
267
+ async function dispatch(action, { issueNumber }) {
268
+ switch (action) {
269
+ case 'generate-spec': {
270
+ const { generateSpec } = await import('./generate-spec.mjs');
271
+ return await generateSpec({ issueNumber });
272
+ }
273
+ case 'generate-plan': {
274
+ const { generatePlan } = await import('./generate-plan.mjs');
275
+ return await generatePlan({ issueNumber });
276
+ }
277
+ case 'critique': {
278
+ const { critique } = await import('./generate-plan.mjs');
279
+ return await critique({ issueNumber });
280
+ }
281
+ case 'validate': {
282
+ const { validate } = await import('./validate.mjs');
283
+ return await validate({ issueNumber });
284
+ }
285
+ case 'decompose': {
286
+ const { decompose } = await import('./decompose.mjs');
287
+ return await decompose({ issueNumber, apply: false });
288
+ }
289
+ case 'decompose-apply': {
290
+ const { decompose } = await import('./decompose.mjs');
291
+ return await decompose({ issueNumber, apply: true });
292
+ }
293
+ case 'generate-bug': {
294
+ const { generateBug } = await import('./generate-bug.mjs');
295
+ return await generateBug({ issueNumber });
296
+ }
297
+ default:
298
+ throw new Error(`Passo sem despacho: ${action}`);
299
+ }
300
+ }
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // Modo PR
304
+ // ---------------------------------------------------------------------------
305
+
306
+ async function runForPr({ prNumber, dryRun, yes, only, json }) {
307
+ const { owner, repo, root } = resolveFlowContext({ command: 'run --pr' });
308
+ const token = await resolveToken();
309
+ const n = parseInt(prNumber, 10);
310
+
311
+ const pr = await getPR(token, owner, repo, n);
312
+ const reviews = await listPullRequestReviews(token, owner, repo, n);
313
+ const verdict = reviewVerdict(reviews);
314
+ const decision = nextPrStep({
315
+ state: pr.state,
316
+ draft: Boolean(pr.draft),
317
+ merged: Boolean(pr.merged_at),
318
+ approved: verdict.approved,
319
+ changesRequestedAfterApproval: verdict.changesRequestedAfterApproval,
320
+ only: only || null,
321
+ });
322
+
323
+ const { error: boardError } = loadProjectConfig({ cwd: root || process.cwd() });
324
+ if (boardError) decision.warnings.push(`${boardError} — o board não será atualizado.`);
325
+
326
+ if (json) {
327
+ console.log(JSON.stringify({ pr: n, ...decision, approvers: verdict.approvers }, null, 2));
328
+ } else {
329
+ console.log(chalk.bold(`\nPR #${n} · ${pr.title}`));
330
+ console.log(`Passos: ${decision.steps.length ? chalk.cyan(decision.steps.join(' → ')) : '(nenhum)'}`);
331
+ console.log(`Motivo: ${decision.reason}`);
332
+ for (const aviso of decision.warnings) console.warn(chalk.yellow(`⚠️ ${aviso}`));
333
+ if (decision.blocked) console.log(chalk.yellow(`\n⛔ ${decision.blocked.code}: ${decision.blocked.message}`));
334
+ }
335
+
336
+ if (decision.blocked || decision.steps.length === 0) {
337
+ process.exitCode = decision.blocked ? 2 : 0;
338
+ return decision;
339
+ }
340
+ if (verdict.changesRequestedAfterApproval && !yes) {
341
+ console.log(chalk.yellow('\n⛔ needs-confirmation: há pedido de mudanças além da aprovação. Confirme com `--yes`.'));
342
+ process.exitCode = 2;
343
+ return decision;
344
+ }
345
+ if (dryRun) {
346
+ console.log(chalk.dim('\nDry-run: nada foi executado.'));
347
+ return decision;
348
+ }
349
+
350
+ const lock = acquireLock(root, `pr-${n}`);
351
+ try {
352
+ for (const step of decision.steps) {
353
+ console.log(chalk.bold(`\n▶ ${step} --pr-number ${n}`));
354
+ if (step === 'code-review') {
355
+ const { codeReview } = await import('./code-review.mjs');
356
+ await codeReview({ prNumber: String(n) });
357
+ } else {
358
+ const { qa } = await import('./qa.mjs');
359
+ await qa({ prNumber: String(n) });
360
+ }
361
+ }
362
+ } finally {
363
+ releaseLock(lock);
364
+ }
365
+ return decision;
366
+ }
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // Comando
370
+ // ---------------------------------------------------------------------------
371
+
372
+ export async function run(issueArg, options = {}) {
373
+ assertNotInActions();
374
+
375
+ const {
376
+ pr, dryRun = false, yes = false, apply = false, step: forced = null,
377
+ maxSteps = 1, force = false, remoteCheck = true, json = false, only = null,
378
+ } = options;
379
+
380
+ if (pr) return await runForPr({ prNumber: pr, dryRun, yes, only, json });
381
+
382
+ if (!issueArg) throw new Error('Informe o número da issue: `spec-wave run <issue>` (ou `--pr <n>`).');
383
+
384
+ const { owner, repo, root, config } = resolveFlowContext({ command: 'run' });
385
+ const token = await resolveToken();
386
+ const issueNumber = String(issueArg).replace(/^#/, '');
387
+ const teto = Math.max(1, parseInt(maxSteps, 10) || 1);
388
+
389
+ if (configuredMode(config) !== 'local') {
390
+ console.warn(chalk.yellow(
391
+ '⚠️ Este repositório está em modo `actions` — os workflows continuam armados e podem ' +
392
+ 'rodar o mesmo passo. Use `spec-wave mode local` para desarmá-los.'
393
+ ));
394
+ }
395
+
396
+ const lock = acquireLock(root, issueNumber);
397
+ // `critiqueFile` seta process.exitCode = 1; sem preservar, um run inteiro
398
+ // bem-sucedido sairia com código de erro.
399
+ const exitCodeAntes = process.exitCode;
400
+ try {
401
+ let ultimaDecisao = null;
402
+ // `--json` acumula e imprime UMA vez no fim (ver renderRunJson).
403
+ const passos = [];
404
+ let ultimoTipo = null;
405
+
406
+ for (let i = 0; i < teto; i++) {
407
+ const issue = await getIssue(token, owner, repo, parseInt(issueNumber, 10));
408
+ const type = detectIssueType(issue);
409
+ const { docs, docPaths } = localDocStates(issue, type, root);
410
+ const { error: boardError } = loadProjectConfig({ cwd: root || process.cwd() });
411
+
412
+ let forcedStep = null;
413
+ if (forced && i === 0) {
414
+ const resolvido = resolveForcedStep(forced, { type });
415
+ if (resolvido.error) throw new Error(resolvido.error);
416
+ forcedStep = resolvido.action;
417
+ }
418
+
419
+ const entrada = {
420
+ type,
421
+ issueNumber,
422
+ state: issue.state,
423
+ labels: labelNames(issue),
424
+ docPaths,
425
+ forcedStep,
426
+ confirmed: yes || apply,
427
+ boardReady: !boardError,
428
+ force,
429
+ };
430
+
431
+ let decision = nextStep({ ...entrada, docs });
432
+ if (remoteCheck) {
433
+ const alvos = docsToProbe(decision, type).filter(doc => docs[doc] === 'missing');
434
+ if (alvos.length > 0) {
435
+ const docsRemotos = await probeRemote({ alvos, docs, docPaths, token, owner, repo });
436
+ decision = nextStep({ ...entrada, docs: docsRemotos });
437
+ }
438
+ }
439
+ ultimaDecisao = decision;
440
+
441
+ // `--apply` é uma asserção sobre o estado esperado, não um "faça o que for":
442
+ // se o passo pendente virou outro, errar é melhor que sobrescrever um documento.
443
+ if (apply && decision.action !== 'decompose-apply' && !yes) {
444
+ throw new Error(
445
+ `--apply autoriza especificamente o \`decompose-apply\`, mas o passo pendente é ` +
446
+ `\`${decision.action}\`. Use --yes se era isso mesmo que você queria.`
447
+ );
448
+ }
449
+
450
+ ultimoTipo = type;
451
+ if (json) passos.push(decision);
452
+ else reportDecision(decision, { issueNumber, type, title: issue.title, warnings: parityWarnings(issue, type) });
453
+
454
+ if (decision.blocked) { process.exitCode = 2; break; }
455
+ if (decision.action === 'none') break;
456
+ if (dryRun) {
457
+ console.log(chalk.dim('\nDry-run: nada foi executado.'));
458
+ break;
459
+ }
460
+
461
+ console.log(chalk.bold(`\n▶ ${decision.command}\n`));
462
+ const resultado = await dispatch(decision.action, { issueNumber });
463
+ process.exitCode = exitCodeAntes;
464
+
465
+ // Reprova do validate é desfecho esperado, não exceção — mas encadear em
466
+ // cima dela repetiria o mesmo passo até esgotar `--max-steps`.
467
+ if (resultado?.ok === false) {
468
+ console.warn(chalk.yellow('\n⚠️ O passo terminou reprovado — corrija os problemas acima e repita.'));
469
+ process.exitCode = 2;
470
+ break;
471
+ }
472
+
473
+ const pendentes = unpushedCommits(root);
474
+ if (pendentes > 0) {
475
+ console.warn(chalk.yellow(
476
+ `\n⚠️ ${pendentes} commit(s) ainda não publicado(s). O próximo passo leria um documento ` +
477
+ 'que só existe no seu clone — publique antes de continuar.'
478
+ ));
479
+ break;
480
+ }
481
+ }
482
+
483
+ if (json) {
484
+ console.log(renderRunJson({ issueNumber, type: ultimoTipo, ultimaDecisao, passos }));
485
+ }
486
+
487
+ return ultimaDecisao;
488
+ } finally {
489
+ releaseLock(lock);
490
+ }
491
+ }