@spec-wave/cli 0.25.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.
package/bin/spec-wave.mjs CHANGED
@@ -1,378 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { program } from 'commander';
4
- import { fileURLToPath } from 'node:url';
5
- import path from 'node:path';
6
- import { readFileSync } from 'node:fs';
3
+ // Ponto de entrada: a montagem do programa vive em ../src/cli.mjs para que ela
4
+ // possa ser importada (doc gerada, testes de superfície) sem executar a CLI.
7
5
 
8
- const __dir = path.dirname(fileURLToPath(import.meta.url));
9
- const pkg = JSON.parse(readFileSync(path.join(__dir, '..', 'package.json'), 'utf-8'));
6
+ import { buildProgram } from '../src/cli.mjs';
10
7
 
11
- program
12
- .name('spec-wave')
13
- .description('Setup spec-driven GitHub workflow with Projects v2')
14
- .version(pkg.version)
15
- // Conta do `gh` a usar nesta execução. Existe porque um GH_TOKEN exportado no
16
- // shell (um `.envrc` na raiz de um diretório de projetos, por exemplo) vale
17
- // para repositórios de QUALQUER org abaixo dele — e o GraphQL do GitHub
18
- // mascara o 403 resultante como "Could not resolve to a Repository".
19
- .option('--account <login>', 'Conta do gh a usar (vence GITHUB_TOKEN/GH_TOKEN fora do CI)')
20
- .hook('preAction', async (thisCommand) => {
21
- const { account } = thisCommand.opts();
22
- if (account) {
23
- const { setAccountOverride } = await import('../src/api/auth.mjs');
24
- setAccountOverride(account);
25
- }
26
- });
27
-
28
- program
29
- .command('init')
30
- .description('Configura spec-wave em um repositório GitHub')
31
- .option('--dry-run', 'Simula a configuração sem fazer alterações')
32
- .option('--repo <owner/repo>', 'Repositório GitHub (ignora o wizard interativo)')
33
- .option('--project-title <title>', 'Nome do GitHub Project (padrão: "<repo> — Spec Wave")')
34
- .option('--skip-project', 'Pula a criação do GitHub Project (use se já foi criado)')
35
- .option('--skip-labels', 'Pula a criação das labels')
36
- .option('--skip-files', 'Pula a criação dos arquivos de workflow')
37
- .option('--provider <provider>', 'Provider de IA dos workflows: anthropic ou openrouter')
38
- .option('--model <model>', 'Modelo de IA usado pelos workflows (ex.: anthropic/claude-3.7-sonnet)')
39
- .action(async (options) => {
40
- const { init } = await import('../src/commands/init.mjs');
41
- await init(options);
42
- });
43
-
44
- program
45
- .command('info')
46
- .description('Mostra se o repositório atual foi inicializado e os dados do .spec-wave.json')
47
- .option('--json', 'Saída em JSON (para uso programático)')
48
- .action(async (options) => {
49
- const { info } = await import('../src/commands/info.mjs');
50
- await info(options);
51
- });
52
-
53
- program
54
- .command('refresh')
55
- .description('Atualiza o .spec-wave.json local com os dados atuais do GitHub Project')
56
- .option('--config', 'Re-consulta o Project e reescreve o .spec-wave.json')
57
- .option('--stages', 'Acrescenta ao campo "Etapa" as colunas canônicas que faltam (nunca remove)')
58
- .option('--dry-run', 'Com --stages: mostra o que seria enviado e não escreve nada')
59
- .option('--yes', 'Com --stages: não pede confirmação')
60
- .action(async (options) => {
61
- const { refresh } = await import('../src/commands/refresh.mjs');
62
- await refresh(options).catch(err => { console.error(err.message); process.exit(1); });
63
- });
64
-
65
- program
66
- .command('issue')
67
- .description('Cria um work item (initiative/epic/feature/story/task...), opcionalmente como sub-issue, e adiciona ao board')
68
- .requiredOption('--title <title>', 'Título (sem o prefixo de tipo, ex.: [FEATURE])')
69
- .option('--type <type>', 'Tipo: initiative, epic, feature, story, task, bug, spike ou rfc', 'feature')
70
- .option('--parent <n>', 'Número da issue pai (cria como sub-issue dela)')
71
- .option('--body <text>', 'Descrição')
72
- .option('--priority <p>', 'Prioridade: P0, P1, P2 ou P3')
73
- .option('--area <area>', 'Área: Frontend, Backend, Mobile, Infra, DevOps ou Data')
74
- .action(async (options) => {
75
- const { issue } = await import('../src/commands/issue.mjs');
76
- await issue(options).catch(err => { console.error(err.message); process.exit(1); });
77
- });
78
-
79
- program
80
- .command('initiative')
81
- .description('Atalho de `issue --type initiative` (nó raiz que agrupa Epics)')
82
- .requiredOption('--title <title>', 'Título da initiative (sem o prefixo [INITIATIVE])')
83
- .option('--body <text>', 'Descrição da initiative')
84
- .option('--priority <p>', 'Prioridade: P0, P1, P2 ou P3 (adiciona label)')
85
- .option('--area <area>', 'Área: Frontend, Backend, Mobile, Infra, DevOps ou Data')
86
- .action(async (options) => {
87
- const { initiative } = await import('../src/commands/initiative.mjs');
88
- await initiative(options).catch(err => { console.error(err.message); process.exit(1); });
89
- });
90
-
91
- program
92
- .command('feature')
93
- .description('Atalho de `issue --type feature`')
94
- .requiredOption('--title <title>', 'Título da feature (sem o prefixo [FEATURE])')
95
- .option('--parent <n>', 'Número da Epic pai (cria como sub-issue dela)')
96
- .option('--body <text>', 'Descrição da feature')
97
- .option('--priority <p>', 'Prioridade: P0, P1, P2 ou P3 (adiciona label)')
98
- .option('--area <area>', 'Área: Frontend, Backend, Mobile, Infra, DevOps ou Data')
99
- .action(async (options) => {
100
- const { feature } = await import('../src/commands/feature.mjs');
101
- await feature(options).catch(err => { console.error(err.message); process.exit(1); });
102
- });
103
-
104
- program
105
- .command('bug')
106
- .description('Cria um Bug (atalho de `issue --type bug`) — nasce em 🐞 Triagem, ou ✅ Ready se P0')
107
- .requiredOption('--title <title>', 'Título (sem o prefixo [BUG])')
108
- .option('--parent <n>', 'Feature ou Story afetada (cria como sub-issue dela)')
109
- .option('--body <text>', 'Descrição: passos, esperado e obtido')
110
- .option('--priority <p>', 'Severidade: P0, P1, P2 ou P3')
111
- .option('--area <area>', 'Área: Frontend, Backend, Mobile, Infra, DevOps ou Data')
112
- .action(async (options) => {
113
- const { bug } = await import('../src/commands/bug.mjs');
114
- await bug(options).catch(err => { console.error(err.message); process.exit(1); });
115
- });
116
-
117
- program
118
- .command('triage')
119
- .description('Tria um Bug: accept (→ ✅ Ready), reject (fecha) ou duplicate (fecha)')
120
- .argument('<action>', 'accept | reject | duplicate')
121
- .argument('<issue>', 'Número da issue do Bug')
122
- .option('--reason <texto>', 'Motivo da rejeição (obrigatório em reject)')
123
- .option('--of <n>', 'Número da issue original (obrigatório em duplicate)')
124
- .option('--severity <p>', 'Reclassifica a severidade ao aceitar: P0–P3')
125
- .action(async (action, issue, options) => {
126
- const { triage } = await import('../src/commands/triage.mjs');
127
- await triage({ action, issue, ...options })
128
- .catch(err => { console.error(err.message); process.exit(1); });
129
- });
130
-
131
- program
132
- .command('run')
133
- .description('Executa LOCALMENTE o próximo passo do fluxo (o que a label dispararia no Actions)')
134
- .argument('[issue]', 'Número da issue (Feature, Bug ou RFC)')
135
- .option('--pr <n>', 'Modo PR: decide entre code-review e qa pelo estado das reviews')
136
- .option('--dry-run', 'Decide e explica sem executar nada')
137
- .option('--yes', 'Confirma o passo que exige confirmação')
138
- .option('--apply', 'Autoriza especificamente o decompose-apply (erra se o passo pendente for outro)')
139
- .option('--step <nome>', 'Força um passo: spec | plan | critique | validate | decompose | decompose-apply | bug')
140
- .option('--max-steps <n>', 'Encadeia até N passos (padrão: 1)', '1')
141
- .option('--force', 'Ignora o portão de label de gatilho pendente')
142
- .option('--no-remote-check', 'Não consulta o remoto pelos documentos ausentes (offline)')
143
- .option('--only <passo>', 'No modo --pr: roda só code-review ou só qa')
144
- .option('--json', 'Imprime a decisão em JSON')
145
- .action(async (issue, options) => {
146
- const { run } = await import('../src/commands/run.mjs');
147
- await run(issue, options).catch(err => { console.error(err.message); process.exit(1); });
148
- });
149
-
150
- program
151
- .command('mode')
152
- .description('Mostra ou alterna o modo de execução: `actions` (workflows) ou `local` (esta máquina)')
153
- .argument('[modo]', 'actions | local (sem argumento: só mostra o estado)')
154
- .option('--dry-run', 'Mostra o que mudaria sem alterar nada')
155
- .action(async (target, options) => {
156
- const { mode } = await import('../src/commands/mode.mjs');
157
- await mode({ target, ...options }).catch(err => { console.error(err.message); process.exit(1); });
158
- });
159
-
160
- program
161
- .command('update')
162
- .description('Detecta o que está desatualizado (skill, .spec-wave.json, workflows/labels do repo) e atualiza só o que mudou')
163
- .option('--global', 'Verifica a skill no escopo do usuário (padrão: projeto)')
164
- .option('--skip-skill', 'Não verifica/atualiza a skill instalada')
165
- .option('--skip-config', 'Não verifica/atualiza o .spec-wave.json local')
166
- .option('--skip-repo', 'Não verifica/atualiza workflows e labels do repo')
167
- .option('--branch [nome]', 'Envia os arquivos do repo como Pull Request numa branch, em um único commit (sem valor: spec-wave/update-v<versão>)')
168
- .option('--config-in-pr', 'Força incluir o .spec-wave.json no Pull Request')
169
- .option('--no-config-in-pr', 'Força manter o .spec-wave.json fora do Pull Request')
170
- .option('--dry-run', 'Mostra o que seria atualizado sem alterar nada')
171
- .option('--yes', 'Aplica sem pedir confirmação')
172
- .action(async (options) => {
173
- const { update } = await import('../src/commands/update.mjs');
174
- await update(options).catch(err => { console.error(err.message); process.exit(1); });
175
- });
176
-
177
- program
178
- .command('install-skill')
179
- .description('Instala a skill spec-wave no(s) agente(s) detectado(s): Claude Code, Codex, Cursor, opencode, Cline, Kilo, Antigravity, AGENTS.md')
180
- .option('--agent <names>', 'Agente(s) alvo, separados por vírgula (pula a detecção)')
181
- .option('--all', 'Instala em todos os agentes detectados')
182
- .option('--global', 'Instala no escopo do usuário (padrão: projeto)')
183
- .option('--dry-run', 'Mostra o que seria instalado sem gravar')
184
- .option('--force', 'Sobrescreve arquivos existentes sem confirmar')
185
- .option('--yes', 'Modo não-interativo')
186
- .action(async (options) => {
187
- const { installSkill } = await import('../src/commands/install-skill.mjs');
188
- await installSkill(options).catch(err => { console.error(err.message); process.exit(1); });
189
- });
190
-
191
- program
192
- .command('uninstall')
193
- .description('Remove labels, arquivos .github e o .spec-wave.json (mantém o GitHub Project)')
194
- .option('--repo <owner/repo>', 'Repositório (padrão: lê do .spec-wave.json)')
195
- .option('--skip-labels', 'Não remove as labels')
196
- .option('--skip-files', 'Não remove os arquivos .github')
197
- .option('--keep-config', 'Mantém o .spec-wave.json local')
198
- .option('--dry-run', 'Mostra o que seria removido sem alterar nada')
199
- .option('--yes', 'Não pede confirmação')
200
- .action(async (options) => {
201
- const { uninstall } = await import('../src/commands/uninstall.mjs');
202
- await uninstall(options).catch(err => { console.error(err.message); process.exit(1); });
203
- });
204
-
205
- program
206
- .command('generate-plan')
207
- .description('Gera plan.md para uma Feature — roda no GitHub Action ou localmente')
208
- .requiredOption('--issue-number <n>', 'Número da issue no GitHub')
209
- .action(async (options) => {
210
- const { generatePlan } = await import('../src/commands/generate-plan.mjs');
211
- await generatePlan(options).catch(err => { console.error(err.message); process.exit(1); });
212
- });
213
-
214
- program
215
- .command('critique')
216
- .description('Critica um documento COMO ESTÁ, sem regerar — para depois de corrigi-lo à mão')
217
- .option('--issue-number <n>', 'Número da issue: critica o plan.md dela e comenta (fluxo canônico)')
218
- .option('--file <caminho>', 'Critica ESTE arquivo e imprime o resultado — sem issue, sem label, sem contar tentativa')
219
- .option('--kind <tipo>', 'plan | spec | stories | bug (default: inferido do nome do arquivo)')
220
- .option('--fail-on-grave', 'Sai com código 1 se houver finding grave (útil em script)')
221
- .action(async (options) => {
222
- const { critique } = await import('../src/commands/generate-plan.mjs');
223
- await critique(options).catch(err => { console.error(err.message); process.exit(1); });
224
- });
225
-
226
- program
227
- .command('generate-spec')
228
- .description('Gera spec.md para uma Feature — roda no GitHub Action ou localmente')
229
- .requiredOption('--issue-number <n>', 'Número da issue no GitHub')
230
- .action(async (options) => {
231
- const { generateSpec } = await import('../src/commands/generate-spec.mjs');
232
- await generateSpec(options).catch(err => { console.error(err.message); process.exit(1); });
233
- });
234
-
235
- program
236
- .command('generate-bug')
237
- .description('Gera bug.md para um Bug — roda no GitHub Action ou localmente')
238
- .requiredOption('--issue-number <n>', 'Número da issue no GitHub')
239
- .action(async (options) => {
240
- const { generateBug } = await import('../src/commands/generate-bug.mjs');
241
- await generateBug(options).catch(err => { console.error(err.message); process.exit(1); });
242
- });
243
-
244
- program
245
- .command('validate')
246
- .description('Valida os documentos de uma issue: spec.md+plan.md de Feature, bug.md de Bug (usado pelo GitHub Action)')
247
- .requiredOption('--issue-number <n>', 'Número da issue no GitHub')
248
- .action(async (options) => {
249
- const { validate } = await import('../src/commands/validate.mjs');
250
- // A reprova é um desfecho esperado do comando, não uma exceção — mas o exit
251
- // code precisa continuar 1 para o job do Actions ficar vermelho.
252
- const result = await validate(options)
253
- .catch(err => { console.error(err.message); process.exit(1); });
254
- if (result?.ok === false) process.exit(1);
255
- });
256
-
257
- program
258
- .command('decompose')
259
- .description('Gera o rascunho da decomposição em decomposition.md; com --apply, cria as Stories/Tasks a partir do rascunho revisado — roda no Action ou localmente')
260
- .requiredOption('--issue-number <n>', 'Número da issue no GitHub')
261
- .option('--apply', 'Aplica o decomposition.md já revisado: cria as issues (sem esta flag, apenas gera/critica o rascunho)')
262
- .action(async (options) => {
263
- const { decompose } = await import('../src/commands/decompose.mjs');
264
- await decompose(options).catch(err => { console.error(err.message); process.exit(1); });
265
- });
266
-
267
- program
268
- .command('code-review')
269
- .description('Move Feature para Code Review ao abrir um PR (usado pelo GitHub Action)')
270
- .requiredOption('--pr-number <n>', 'Número do Pull Request')
271
- .option('--resolve-only', 'Só descobre a Feature-alvo e imprime feature=<n>, sem tocar no board')
272
- .action(async (options) => {
273
- const { codeReview } = await import('../src/commands/code-review.mjs');
274
- await codeReview(options).catch(err => { console.error(err.message); process.exit(1); });
275
- });
276
-
277
- program
278
- .command('qa')
279
- .description('Move Feature para QA ao aprovar um PR (usado pelo GitHub Action)')
280
- .requiredOption('--pr-number <n>', 'Número do Pull Request')
281
- .action(async (options) => {
282
- const { qa } = await import('../src/commands/qa.mjs');
283
- await qa(options).catch(err => { console.error(err.message); process.exit(1); });
284
- });
285
-
286
- program
287
- .command('implement')
288
- .description('Aciona o spec-kit implement para uma Feature (Stories pendentes em ordem de dependência), uma Story (todas as tasks) ou uma Task')
289
- .argument('<issue>', 'Número da issue (Feature, Story ou Task), ex.: 12 ou #12')
290
- .option('--feature-dir <path>', 'Caminho do docs/features/<slug> (sobrescreve a resolução automática)')
291
- .option('--dry-run', 'Monta o contexto e imprime o comando sem executar o spec-kit')
292
- .action(async (issue, options) => {
293
- const { implement } = await import('../src/commands/implement.mjs');
294
- await implement({ issue, ...options }).catch(err => { console.error(err.message); process.exit(1); });
295
- });
296
-
297
- program
298
- .command('order')
299
- .description('Ordena as Stories pelas dependências (topológica). Sem argumento, o mapa de todas as Features com trabalho')
300
- .argument('[feature]', 'Número da issue da Feature, ex.: 12 ou #12. Omitido: todas as Features abertas fora de 🎉 Done')
301
- .action(async (feature) => {
302
- const { order } = await import('../src/commands/order.mjs');
303
- await order({ feature }).catch(err => { console.error(err.message); process.exit(1); });
304
- });
305
-
306
- program
307
- .command('task')
308
- .description('Gerencia uma Task no board: start (Status "In Progress") ou done (Done)')
309
- .argument('<action>', 'Ação: start ou done')
310
- .argument('<n>', 'Número da issue da Task, ex.: 12 ou #12')
311
- .action(async (action, n) => {
312
- const { task } = await import('../src/commands/task.mjs');
313
- await task({ action, issue: n }).catch(err => { console.error(err.message); process.exit(1); });
314
- });
315
-
316
- program
317
- .command('move')
318
- .description('Move qualquer item do board (Feature, Story, Task, Bug, RFC) para uma Etapa — a Etapa nunca retrocede')
319
- .argument('<n>', 'Número da issue, ex.: 8 ou #8')
320
- .argument('<etapa>', 'Etapa de destino, com ou sem emoji, ex.: "code review", "Homologação", "🎉 Done"')
321
- .option('--status <valor>', 'Valor do campo Status no destino: Todo, In Progress ou Done (default: Todo)')
322
- .action(async (n, etapa, options) => {
323
- const { move } = await import('../src/commands/move.mjs');
324
- await move({ issue: n, stage: etapa, ...options })
325
- .catch(err => { console.error(err.message); process.exit(1); });
326
- });
327
-
328
- program
329
- .command('repair-stage')
330
- .description('Corrige a Etapa de itens que a automação errou — inclusive retrocedendo. Exige --yes e --reason, e registra o reparo na issue')
331
- .argument('<issues>', 'Número(s) da(s) issue(s), ex.: 529 ou 529,530,531')
332
- .argument('<etapa>', 'Etapa correta, com ou sem emoji, ex.: "ready", "✅ Ready"')
333
- .option('--reason <motivo>', 'Por que o reparo é necessário (vai para o comentário de auditoria)')
334
- .option('--status <valor>', 'Também corrige o Status: Todo, In Progress ou Done (default: não mexe)')
335
- .option('--yes', 'Confirma o reparo (obrigatório)')
336
- .option('--dry-run', 'Mostra o que seria reparado sem alterar nada')
337
- .action(async (issues, etapa, options) => {
338
- const { repairStage } = await import('../src/commands/repair-stage.mjs');
339
- await repairStage(issues, etapa, options)
340
- .catch(err => { console.error(err.message); process.exit(1); });
341
- });
342
-
343
- program
344
- .command('story')
345
- .description('Gerencia uma Story no board: review (move para Code Review)')
346
- .argument('<action>', 'Ação: review')
347
- .argument('<n>', 'Número da issue da Story, ex.: 12 ou #12')
348
- .action(async (action, n) => {
349
- const { story } = await import('../src/commands/story.mjs');
350
- await story({ action, issue: n }).catch(err => { console.error(err.message); process.exit(1); });
351
- });
352
-
353
- program
354
- .command('dev-agent')
355
- .description('Instala (--install/--build) ou executa (--run) o spec-wave-agent nesta máquina')
356
- .option('--install', 'Baixa o binário da release, gera a config e (com --service) o serviço')
357
- .option('--build', 'Clona o repo do agente e compila com cargo (alternativa ao --install)')
358
- .option('--run', 'Executa o agente em foreground (Ctrl+C encerra com checkpoint)')
359
- .option('--service', 'No --install/--build: também instala e habilita systemd/launchd')
360
- .option('--tag <tag>', 'Release (--install) ou branch/tag (--build); padrão: última release / main')
361
- .option('--debug', 'No --run: RUST_LOG=debug')
362
- .option('--dry-run', 'Mostra o que seria instalado sem gravar')
363
- .option('--force', 'Reinstala o binário e regrava a config')
364
- .option('--yes', 'Modo não-interativo')
365
- .action(async (options) => {
366
- const { devAgent } = await import('../src/commands/dev-agent.mjs');
367
- await devAgent(options).catch(err => { console.error(err.message); process.exit(1); });
368
- });
369
-
370
- program
371
- .command('doctor')
372
- .description('Diagnostica a configuração do spec-wave no repositório atual')
373
- .action(async () => {
374
- const { doctor } = await import('../src/commands/doctor.mjs');
375
- await doctor().catch(err => { console.error(err.message); process.exit(1); });
376
- });
377
-
378
- program.parse();
8
+ buildProgram().parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spec-wave/cli",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "Setup spec-driven GitHub workflow with Projects v2, labels, issue templates, and AI-powered Actions",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs ADDED
@@ -0,0 +1,400 @@
1
+ // Superfície da CLI: monta o programa do Commander e o devolve SEM executá-lo.
2
+ //
3
+ // Vive aqui, e não no `bin/`, porque o `bin/` executa (`parse()`) — importá-lo
4
+ // para introspecção dispararia a CLI. Com a montagem separada, o gerador da
5
+ // documentação (`apps/docs/scripts/generate-reference.mjs`) e os testes leem
6
+ // `program.commands` diretamente, em vez de parsear o texto do `--help`.
7
+
8
+ import { Command } from 'commander';
9
+ import { fileURLToPath } from 'node:url';
10
+ import path from 'node:path';
11
+ import { readFileSync } from 'node:fs';
12
+
13
+ const __dir = path.dirname(fileURLToPath(import.meta.url));
14
+ const pkg = JSON.parse(readFileSync(path.join(__dir, '..', 'package.json'), 'utf-8'));
15
+
16
+ /**
17
+ * Monta o programa do Commander com todos os comandos (função sem efeito).
18
+ *
19
+ * NÃO chama `.parse()` — quem executa é o `bin/spec-wave.mjs`.
20
+ *
21
+ * @returns {Command}
22
+ */
23
+ export function buildProgram() {
24
+ const program = new Command();
25
+
26
+ program
27
+ .name('spec-wave')
28
+ .description('Setup spec-driven GitHub workflow with Projects v2')
29
+ .version(pkg.version)
30
+ // Conta do `gh` a usar nesta execução. Existe porque um GH_TOKEN exportado no
31
+ // shell (um `.envrc` na raiz de um diretório de projetos, por exemplo) vale
32
+ // para repositórios de QUALQUER org abaixo dele — e o GraphQL do GitHub
33
+ // mascara o 403 resultante como "Could not resolve to a Repository".
34
+ .option('--account <login>', 'Conta do gh a usar (vence GITHUB_TOKEN/GH_TOKEN fora do CI)')
35
+ .hook('preAction', async (thisCommand) => {
36
+ const { account } = thisCommand.opts();
37
+ if (account) {
38
+ const { setAccountOverride } = await import('./api/auth.mjs');
39
+ setAccountOverride(account);
40
+ }
41
+ });
42
+
43
+ program
44
+ .command('init')
45
+ .description('Configura spec-wave em um repositório GitHub')
46
+ .option('--dry-run', 'Simula a configuração sem fazer alterações')
47
+ .option('--repo <owner/repo>', 'Repositório GitHub (ignora o wizard interativo)')
48
+ .option('--project-title <title>', 'Nome do GitHub Project (padrão: "<repo> — Spec Wave")')
49
+ .option('--skip-project', 'Pula a criação do GitHub Project (use se já foi criado)')
50
+ .option('--skip-labels', 'Pula a criação das labels')
51
+ .option('--skip-files', 'Pula a criação dos arquivos de workflow')
52
+ .option('--provider <provider>', 'Provider de IA dos workflows: anthropic ou openrouter')
53
+ .option('--model <model>', 'Modelo de IA usado pelos workflows (ex.: anthropic/claude-3.7-sonnet)')
54
+ .action(async (options) => {
55
+ const { init } = await import('./commands/init.mjs');
56
+ await init(options);
57
+ });
58
+
59
+ program
60
+ .command('info')
61
+ .description('Mostra se o repositório atual foi inicializado e os dados do .spec-wave.json')
62
+ .option('--json', 'Saída em JSON (para uso programático)')
63
+ .action(async (options) => {
64
+ const { info } = await import('./commands/info.mjs');
65
+ await info(options);
66
+ });
67
+
68
+ program
69
+ .command('refresh')
70
+ .description('Atualiza o .spec-wave.json local com os dados atuais do GitHub Project')
71
+ .option('--config', 'Re-consulta o Project e reescreve o .spec-wave.json')
72
+ .option('--stages', 'Acrescenta ao campo "Etapa" as colunas canônicas que faltam (nunca remove)')
73
+ .option('--dry-run', 'Com --stages: mostra o que seria enviado e não escreve nada')
74
+ .option('--yes', 'Com --stages: não pede confirmação')
75
+ .action(async (options) => {
76
+ const { refresh } = await import('./commands/refresh.mjs');
77
+ await refresh(options).catch(err => { console.error(err.message); process.exit(1); });
78
+ });
79
+
80
+ program
81
+ .command('issue')
82
+ .description('Cria um work item (initiative/epic/feature/story/task...), opcionalmente como sub-issue, e adiciona ao board')
83
+ .requiredOption('--title <title>', 'Título (sem o prefixo de tipo, ex.: [FEATURE])')
84
+ .option('--type <type>', 'Tipo: initiative, epic, feature, story, task, bug, spike ou rfc', 'feature')
85
+ .option('--parent <n>', 'Número da issue pai (cria como sub-issue dela)')
86
+ .option('--body <text>', 'Descrição')
87
+ .option('--priority <p>', 'Prioridade: P0, P1, P2 ou P3')
88
+ .option('--area <area>', 'Área: Frontend, Backend, Mobile, Infra, DevOps ou Data')
89
+ .action(async (options) => {
90
+ const { issue } = await import('./commands/issue.mjs');
91
+ await issue(options).catch(err => { console.error(err.message); process.exit(1); });
92
+ });
93
+
94
+ program
95
+ .command('initiative')
96
+ .description('Atalho de `issue --type initiative` (nó raiz que agrupa Epics)')
97
+ .requiredOption('--title <title>', 'Título da initiative (sem o prefixo [INITIATIVE])')
98
+ .option('--body <text>', 'Descrição da initiative')
99
+ .option('--priority <p>', 'Prioridade: P0, P1, P2 ou P3 (adiciona label)')
100
+ .option('--area <area>', 'Área: Frontend, Backend, Mobile, Infra, DevOps ou Data')
101
+ .action(async (options) => {
102
+ const { initiative } = await import('./commands/initiative.mjs');
103
+ await initiative(options).catch(err => { console.error(err.message); process.exit(1); });
104
+ });
105
+
106
+ program
107
+ .command('feature')
108
+ .description('Atalho de `issue --type feature`')
109
+ .requiredOption('--title <title>', 'Título da feature (sem o prefixo [FEATURE])')
110
+ .option('--parent <n>', 'Número da Epic pai (cria como sub-issue dela)')
111
+ .option('--body <text>', 'Descrição da feature')
112
+ .option('--priority <p>', 'Prioridade: P0, P1, P2 ou P3 (adiciona label)')
113
+ .option('--area <area>', 'Área: Frontend, Backend, Mobile, Infra, DevOps ou Data')
114
+ .action(async (options) => {
115
+ const { feature } = await import('./commands/feature.mjs');
116
+ await feature(options).catch(err => { console.error(err.message); process.exit(1); });
117
+ });
118
+
119
+ program
120
+ .command('bug')
121
+ .description('Cria um Bug (atalho de `issue --type bug`) — nasce em 🐞 Triagem, ou ✅ Ready se P0')
122
+ .requiredOption('--title <title>', 'Título (sem o prefixo [BUG])')
123
+ .option('--parent <n>', 'Feature ou Story afetada (cria como sub-issue dela)')
124
+ .option('--body <text>', 'Descrição: passos, esperado e obtido')
125
+ .option('--priority <p>', 'Severidade: P0, P1, P2 ou P3')
126
+ .option('--area <area>', 'Área: Frontend, Backend, Mobile, Infra, DevOps ou Data')
127
+ .action(async (options) => {
128
+ const { bug } = await import('./commands/bug.mjs');
129
+ await bug(options).catch(err => { console.error(err.message); process.exit(1); });
130
+ });
131
+
132
+ program
133
+ .command('triage')
134
+ .description('Tria um Bug: accept (→ ✅ Ready), reject (fecha) ou duplicate (fecha)')
135
+ .argument('<action>', 'accept | reject | duplicate')
136
+ .argument('<issue>', 'Número da issue do Bug')
137
+ .option('--reason <texto>', 'Motivo da rejeição (obrigatório em reject)')
138
+ .option('--of <n>', 'Número da issue original (obrigatório em duplicate)')
139
+ .option('--severity <p>', 'Reclassifica a severidade ao aceitar: P0–P3')
140
+ .action(async (action, issue, options) => {
141
+ const { triage } = await import('./commands/triage.mjs');
142
+ await triage({ action, issue, ...options })
143
+ .catch(err => { console.error(err.message); process.exit(1); });
144
+ });
145
+
146
+ program
147
+ .command('run')
148
+ .description('Executa LOCALMENTE o próximo passo do fluxo (o que a label dispararia no Actions)')
149
+ .argument('[issue]', 'Número da issue (Feature, Bug ou RFC)')
150
+ .option('--pr <n>', 'Modo PR: decide entre code-review e qa pelo estado das reviews')
151
+ .option('--dry-run', 'Decide e explica sem executar nada')
152
+ .option('--yes', 'Confirma o passo que exige confirmação')
153
+ .option('--apply', 'Autoriza especificamente o decompose-apply (erra se o passo pendente for outro)')
154
+ .option('--step <nome>', 'Força um passo: spec | plan | critique | validate | decompose | decompose-apply | bug')
155
+ .option('--max-steps <n>', 'Encadeia até N passos (padrão: 1)', '1')
156
+ .option('--force', 'Ignora o portão de label de gatilho pendente')
157
+ .option('--no-remote-check', 'Não consulta o remoto pelos documentos ausentes (offline)')
158
+ .option('--only <passo>', 'No modo --pr: roda só code-review ou só qa')
159
+ .option('--json', 'Imprime a decisão em JSON')
160
+ .action(async (issue, options) => {
161
+ const { run } = await import('./commands/run.mjs');
162
+ await run(issue, options).catch(err => { console.error(err.message); process.exit(1); });
163
+ });
164
+
165
+ program
166
+ .command('mode')
167
+ .description('Mostra ou alterna o modo de execução: `actions` (workflows) ou `local` (esta máquina)')
168
+ .argument('[modo]', 'actions | local (sem argumento: só mostra o estado)')
169
+ .option('--dry-run', 'Mostra o que mudaria sem alterar nada')
170
+ .action(async (target, options) => {
171
+ const { mode } = await import('./commands/mode.mjs');
172
+ const result = await mode({ target, ...options })
173
+ .catch(err => { console.error(err.message); process.exit(1); });
174
+ // Escrever só o config é meio-interruptor: a CLI passa a agir como local
175
+ // e os workflows continuam disparando. O comando já avisa em vermelho,
176
+ // mas sair 0 fazia qualquer script (ou agente) que o encadeasse ler
177
+ // sucesso — e seguir em frente achando que o CI estava desligado.
178
+ if (result?.variableApplied === false) process.exit(1);
179
+ });
180
+
181
+ program
182
+ .command('update')
183
+ .description('Detecta o que está desatualizado (skill, .spec-wave.json, workflows/labels do repo) e atualiza só o que mudou')
184
+ .option('--global', 'Verifica a skill no escopo do usuário (padrão: projeto)')
185
+ .option('--skip-skill', 'Não verifica/atualiza a skill instalada')
186
+ .option('--skip-config', 'Não verifica/atualiza o .spec-wave.json local')
187
+ .option('--skip-repo', 'Não verifica/atualiza workflows e labels do repo')
188
+ .option('--branch [nome]', 'Envia os arquivos do repo como Pull Request numa branch, em um único commit (sem valor: spec-wave/update-v<versão>)')
189
+ .option('--config-in-pr', 'Força incluir o .spec-wave.json no Pull Request')
190
+ .option('--no-config-in-pr', 'Força manter o .spec-wave.json fora do Pull Request')
191
+ .option('--dry-run', 'Mostra o que seria atualizado sem alterar nada')
192
+ .option('--yes', 'Aplica sem pedir confirmação')
193
+ .action(async (options) => {
194
+ const { update } = await import('./commands/update.mjs');
195
+ await update(options).catch(err => { console.error(err.message); process.exit(1); });
196
+ });
197
+
198
+ program
199
+ .command('install-skill')
200
+ .description('Instala a skill spec-wave no(s) agente(s) detectado(s): Claude Code, Codex, Cursor, opencode, Cline, Kilo, Antigravity, AGENTS.md')
201
+ .option('--agent <names>', 'Agente(s) alvo, separados por vírgula (pula a detecção)')
202
+ .option('--all', 'Instala em todos os agentes detectados')
203
+ .option('--global', 'Instala no escopo do usuário (padrão: projeto)')
204
+ .option('--dry-run', 'Mostra o que seria instalado sem gravar')
205
+ .option('--force', 'Sobrescreve arquivos existentes sem confirmar')
206
+ .option('--yes', 'Modo não-interativo')
207
+ .action(async (options) => {
208
+ const { installSkill } = await import('./commands/install-skill.mjs');
209
+ await installSkill(options).catch(err => { console.error(err.message); process.exit(1); });
210
+ });
211
+
212
+ program
213
+ .command('uninstall')
214
+ .description('Remove labels, arquivos .github e o .spec-wave.json (mantém o GitHub Project)')
215
+ .option('--repo <owner/repo>', 'Repositório (padrão: lê do .spec-wave.json)')
216
+ .option('--skip-labels', 'Não remove as labels')
217
+ .option('--skip-files', 'Não remove os arquivos .github')
218
+ .option('--keep-config', 'Mantém o .spec-wave.json local')
219
+ .option('--dry-run', 'Mostra o que seria removido sem alterar nada')
220
+ .option('--yes', 'Não pede confirmação')
221
+ .action(async (options) => {
222
+ const { uninstall } = await import('./commands/uninstall.mjs');
223
+ await uninstall(options).catch(err => { console.error(err.message); process.exit(1); });
224
+ });
225
+
226
+ program
227
+ .command('generate-plan')
228
+ .description('Gera plan.md para uma Feature — roda no GitHub Action ou localmente')
229
+ .requiredOption('--issue-number <n>', 'Número da issue no GitHub')
230
+ .action(async (options) => {
231
+ const { generatePlan } = await import('./commands/generate-plan.mjs');
232
+ await generatePlan(options).catch(err => { console.error(err.message); process.exit(1); });
233
+ });
234
+
235
+ program
236
+ .command('critique')
237
+ .description('Critica um documento COMO ESTÁ, sem regerar — para depois de corrigi-lo à mão')
238
+ .option('--issue-number <n>', 'Número da issue: critica o plan.md dela e comenta (fluxo canônico)')
239
+ .option('--file <caminho>', 'Critica ESTE arquivo e imprime o resultado — sem issue, sem label, sem contar tentativa')
240
+ .option('--kind <tipo>', 'plan | spec | stories | bug (default: inferido do nome do arquivo)')
241
+ .option('--fail-on-grave', 'Sai com código 1 se houver finding grave (útil em script)')
242
+ .action(async (options) => {
243
+ const { critique } = await import('./commands/generate-plan.mjs');
244
+ await critique(options).catch(err => { console.error(err.message); process.exit(1); });
245
+ });
246
+
247
+ program
248
+ .command('generate-spec')
249
+ .description('Gera spec.md para uma Feature — roda no GitHub Action ou localmente')
250
+ .requiredOption('--issue-number <n>', 'Número da issue no GitHub')
251
+ .action(async (options) => {
252
+ const { generateSpec } = await import('./commands/generate-spec.mjs');
253
+ await generateSpec(options).catch(err => { console.error(err.message); process.exit(1); });
254
+ });
255
+
256
+ program
257
+ .command('generate-bug')
258
+ .description('Gera bug.md para um Bug — roda no GitHub Action ou localmente')
259
+ .requiredOption('--issue-number <n>', 'Número da issue no GitHub')
260
+ .action(async (options) => {
261
+ const { generateBug } = await import('./commands/generate-bug.mjs');
262
+ await generateBug(options).catch(err => { console.error(err.message); process.exit(1); });
263
+ });
264
+
265
+ program
266
+ .command('validate')
267
+ .description('Valida os documentos de uma issue: spec.md+plan.md de Feature, bug.md de Bug (usado pelo GitHub Action)')
268
+ .requiredOption('--issue-number <n>', 'Número da issue no GitHub')
269
+ .action(async (options) => {
270
+ const { validate } = await import('./commands/validate.mjs');
271
+ // A reprova é um desfecho esperado do comando, não uma exceção — mas o exit
272
+ // code precisa continuar 1 para o job do Actions ficar vermelho.
273
+ const result = await validate(options)
274
+ .catch(err => { console.error(err.message); process.exit(1); });
275
+ if (result?.ok === false) process.exit(1);
276
+ });
277
+
278
+ program
279
+ .command('decompose')
280
+ .description('Gera o rascunho da decomposição em decomposition.md; com --apply, cria as Stories/Tasks a partir do rascunho revisado — roda no Action ou localmente')
281
+ .requiredOption('--issue-number <n>', 'Número da issue no GitHub')
282
+ .option('--apply', 'Aplica o decomposition.md já revisado: cria as issues (sem esta flag, apenas gera/critica o rascunho)')
283
+ .action(async (options) => {
284
+ const { decompose } = await import('./commands/decompose.mjs');
285
+ await decompose(options).catch(err => { console.error(err.message); process.exit(1); });
286
+ });
287
+
288
+ program
289
+ .command('code-review')
290
+ .description('Move Feature para Code Review ao abrir um PR (usado pelo GitHub Action)')
291
+ .requiredOption('--pr-number <n>', 'Número do Pull Request')
292
+ .option('--resolve-only', 'Só descobre a Feature-alvo e imprime feature=<n>, sem tocar no board')
293
+ .action(async (options) => {
294
+ const { codeReview } = await import('./commands/code-review.mjs');
295
+ await codeReview(options).catch(err => { console.error(err.message); process.exit(1); });
296
+ });
297
+
298
+ program
299
+ .command('qa')
300
+ .description('Move Feature para QA ao aprovar um PR (usado pelo GitHub Action)')
301
+ .requiredOption('--pr-number <n>', 'Número do Pull Request')
302
+ .action(async (options) => {
303
+ const { qa } = await import('./commands/qa.mjs');
304
+ await qa(options).catch(err => { console.error(err.message); process.exit(1); });
305
+ });
306
+
307
+ program
308
+ .command('implement')
309
+ .description('Aciona o spec-kit implement para uma Feature (Stories pendentes em ordem de dependência), uma Story (todas as tasks) ou uma Task')
310
+ .argument('<issue>', 'Número da issue (Feature, Story ou Task), ex.: 12 ou #12')
311
+ .option('--feature-dir <path>', 'Caminho do docs/features/<slug> (sobrescreve a resolução automática)')
312
+ .option('--dry-run', 'Monta o contexto e imprime o comando sem executar o spec-kit')
313
+ .action(async (issue, options) => {
314
+ const { implement } = await import('./commands/implement.mjs');
315
+ await implement({ issue, ...options }).catch(err => { console.error(err.message); process.exit(1); });
316
+ });
317
+
318
+ program
319
+ .command('order')
320
+ .description('Ordena as Stories pelas dependências (topológica). Sem argumento, o mapa de todas as Features com trabalho')
321
+ .argument('[feature]', 'Número da issue da Feature, ex.: 12 ou #12. Omitido: todas as Features abertas fora de 🎉 Done')
322
+ .action(async (feature) => {
323
+ const { order } = await import('./commands/order.mjs');
324
+ await order({ feature }).catch(err => { console.error(err.message); process.exit(1); });
325
+ });
326
+
327
+ program
328
+ .command('task')
329
+ .description('Gerencia uma Task no board: start (Status "In Progress") ou done (Done)')
330
+ .argument('<action>', 'Ação: start ou done')
331
+ .argument('<n>', 'Número da issue da Task, ex.: 12 ou #12')
332
+ .action(async (action, n) => {
333
+ const { task } = await import('./commands/task.mjs');
334
+ await task({ action, issue: n }).catch(err => { console.error(err.message); process.exit(1); });
335
+ });
336
+
337
+ program
338
+ .command('move')
339
+ .description('Move qualquer item do board (Feature, Story, Task, Bug, RFC) para uma Etapa — a Etapa nunca retrocede')
340
+ .argument('<n>', 'Número da issue, ex.: 8 ou #8')
341
+ .argument('<etapa>', 'Etapa de destino, com ou sem emoji, ex.: "code review", "Homologação", "🎉 Done"')
342
+ .option('--status <valor>', 'Valor do campo Status no destino: Todo, In Progress ou Done (default: Todo)')
343
+ .action(async (n, etapa, options) => {
344
+ const { move } = await import('./commands/move.mjs');
345
+ await move({ issue: n, stage: etapa, ...options })
346
+ .catch(err => { console.error(err.message); process.exit(1); });
347
+ });
348
+
349
+ program
350
+ .command('repair-stage')
351
+ .description('Corrige a Etapa de itens que a automação errou — inclusive retrocedendo. Exige --yes e --reason, e registra o reparo na issue')
352
+ .argument('<issues>', 'Número(s) da(s) issue(s), ex.: 529 ou 529,530,531')
353
+ .argument('<etapa>', 'Etapa correta, com ou sem emoji, ex.: "ready", "✅ Ready"')
354
+ .option('--reason <motivo>', 'Por que o reparo é necessário (vai para o comentário de auditoria)')
355
+ .option('--status <valor>', 'Também corrige o Status: Todo, In Progress ou Done (default: não mexe)')
356
+ .option('--yes', 'Confirma o reparo (obrigatório)')
357
+ .option('--dry-run', 'Mostra o que seria reparado sem alterar nada')
358
+ .action(async (issues, etapa, options) => {
359
+ const { repairStage } = await import('./commands/repair-stage.mjs');
360
+ await repairStage(issues, etapa, options)
361
+ .catch(err => { console.error(err.message); process.exit(1); });
362
+ });
363
+
364
+ program
365
+ .command('story')
366
+ .description('Gerencia uma Story no board: review (move para Code Review)')
367
+ .argument('<action>', 'Ação: review')
368
+ .argument('<n>', 'Número da issue da Story, ex.: 12 ou #12')
369
+ .action(async (action, n) => {
370
+ const { story } = await import('./commands/story.mjs');
371
+ await story({ action, issue: n }).catch(err => { console.error(err.message); process.exit(1); });
372
+ });
373
+
374
+ program
375
+ .command('dev-agent')
376
+ .description('Instala (--install/--build) ou executa (--run) o spec-wave-agent nesta máquina')
377
+ .option('--install', 'Baixa o binário da release, gera a config e (com --service) o serviço')
378
+ .option('--build', 'Clona o repo do agente e compila com cargo (alternativa ao --install)')
379
+ .option('--run', 'Executa o agente em foreground (Ctrl+C encerra com checkpoint)')
380
+ .option('--service', 'No --install/--build: também instala e habilita systemd/launchd')
381
+ .option('--tag <tag>', 'Release (--install) ou branch/tag (--build); padrão: última release / main')
382
+ .option('--debug', 'No --run: RUST_LOG=debug')
383
+ .option('--dry-run', 'Mostra o que seria instalado sem gravar')
384
+ .option('--force', 'Reinstala o binário e regrava a config')
385
+ .option('--yes', 'Modo não-interativo')
386
+ .action(async (options) => {
387
+ const { devAgent } = await import('./commands/dev-agent.mjs');
388
+ await devAgent(options).catch(err => { console.error(err.message); process.exit(1); });
389
+ });
390
+
391
+ program
392
+ .command('doctor')
393
+ .description('Diagnostica a configuração do spec-wave no repositório atual')
394
+ .action(async () => {
395
+ const { doctor } = await import('./commands/doctor.mjs');
396
+ await doctor().catch(err => { console.error(err.message); process.exit(1); });
397
+ });
398
+
399
+ return program;
400
+ }
@@ -157,16 +157,27 @@ const CHILD_PREFIX = { Feature: '[STORY]', RFC: '[TASK]' };
157
157
  /**
158
158
  * Modo do run (função PURA — testável).
159
159
  *
160
- * A flag `--apply` vem do workflow, onde a label que disparou é a fonte de
161
- * verdade. O fallback por label cobre a execução manual e o retry de runner.
160
+ * TRI-ESTADO, e a distinção é o que impede um estrago: `apply` pode ser `true`
161
+ * (aplique), `false` (NÃO aplique) ou `undefined` (ninguém decidiu use a
162
+ * label). Só o `undefined` cai no fallback por label, que é o caminho do
163
+ * workflow (onde a label que disparou é a fonte de verdade) e do retry de
164
+ * runner.
165
+ *
166
+ * Antes, o default `apply = false` colapsava "não aplique" em "ninguém
167
+ * decidiu": o `run` despachava o rascunho com `apply: false` e a label
168
+ * `spec-wave:decompose-apply` esquecida na issue por uma Action que falhou
169
+ * promovia a execução a aplicação. O comando anunciava "gera/re-critica o
170
+ * rascunho" e CRIAVA Stories e Tasks — sem passar pelo portão de confirmação
171
+ * do `run`, que existe exatamente para isso.
162
172
  *
163
173
  * @param {object} params
164
174
  * @param {Array<string|{name:string}>} [params.labels] labels da issue
165
- * @param {boolean} [params.apply] flag --apply
175
+ * @param {boolean} [params.apply] `true`/`false` decidem; `undefined` consulta a label
166
176
  * @returns {'draft'|'apply'}
167
177
  */
168
- export function resolveDecomposeMode({ labels = [], apply = false } = {}) {
169
- if (apply) return 'apply';
178
+ export function resolveDecomposeMode({ labels = [], apply } = {}) {
179
+ if (apply === true) return 'apply';
180
+ if (apply === false) return 'draft';
170
181
  return labelNames(labels).includes(LABEL_DECOMPOSE_APPLY) ? 'apply' : 'draft';
171
182
  }
172
183
 
@@ -917,7 +928,10 @@ export function renderApplyFailureComment({ trigger, message, createdItems = [],
917
928
  'Complete o que falta à mão, ou apague as issues acima antes de tentar de novo.';
918
929
  }
919
930
 
920
- export async function decompose({ issueNumber, apply = false }) {
931
+ // `apply` SEM default: `undefined` significa "ninguém decidiu" e é o que faz o
932
+ // fallback por label valer no workflow. Um default `false` aqui reintroduziria
933
+ // o bug, porque a CLI passa `undefined` quando a flag não é usada.
934
+ export async function decompose({ issueNumber, apply }) {
921
935
  const token = await resolveToken();
922
936
  // PROJECT_TOKEN deve ter scope "project" para atualizar GitHub Projects v2.
923
937
  // Fallback para GITHUB_TOKEN (só funciona em repos pessoais sem org restrictions).
@@ -21,7 +21,7 @@ import { CONFIG_FILE, WORKFLOW_FILES } from '../config.mjs';
21
21
  import { updateConfig } from '../lib/config-file.mjs';
22
22
  import {
23
23
  EXECUTION_MODES, EXECUTION_VARIABLE, EXECUTION_GUARD,
24
- configuredMode, variableValueFor, describeModeState,
24
+ configuredMode, variableValueFor, describeModeState, shouldWriteVariable,
25
25
  } from '../lib/execution-mode.mjs';
26
26
  import { loadConfig } from '../lib/project-root.mjs';
27
27
 
@@ -100,20 +100,31 @@ export async function mode({ target, dryRun = false } = {}) {
100
100
 
101
101
  const esperado = variableValueFor(alvo);
102
102
  const mudaConfig = atual !== alvo;
103
- const mudaVariavel = variavel !== undefined && variavel !== esperado;
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 });
104
112
 
105
113
  if (!mudaConfig && !mudaVariavel) {
106
114
  p.log.success(`Já está em ${chalk.bold(alvo)} — config e variável do repositório coincidem.`);
107
115
  p.outro('Nada a fazer.');
108
- return { mode: alvo, variable: variavel, changed: false };
116
+ return { mode: alvo, variable: variavel, changed: false, variableApplied: true };
109
117
  }
110
118
 
111
119
  if (dryRun) {
112
120
  if (mudaConfig) p.log.info(`${CONFIG_FILE}: execution.mode ${atual} → ${alvo}`);
113
121
  if (mudaVariavel) {
122
+ const atualDaVariavel = variavel === undefined
123
+ ? 'valor atual desconhecido — sem permissão para ler'
124
+ : `valor atual: ${variavel === null ? 'ausente' : variavel}`;
114
125
  p.log.info(esperado === null
115
- ? `Variável ${EXECUTION_VARIABLE}: remover (valor atual: ${variavel})`
116
- : `Variável ${EXECUTION_VARIABLE}: definir como "${esperado}"`);
126
+ ? `Variável ${EXECUTION_VARIABLE}: remover (${atualDaVariavel})`
127
+ : `Variável ${EXECUTION_VARIABLE}: definir como "${esperado}" (${atualDaVariavel})`);
117
128
  }
118
129
  p.outro('Dry-run: nada foi alterado.');
119
130
  return { mode: atual, variable: variavel, changed: false };
@@ -26,7 +26,7 @@ import { existsOnRemote } from '../lib/doc-availability.mjs';
26
26
  import { featureDocPaths, bugDocPaths } from '../lib/doc-paths.mjs';
27
27
  import { isActionsRun, resolveFlowContext } from '../lib/flow-run.mjs';
28
28
  import { detectIssueType } from '../lib/issue-type.mjs';
29
- import { nextStep, resolveForcedStep, STEPS } from '../lib/next-step.mjs';
29
+ import { nextStep, resolveForcedStep, stepDocs, docsForType, STEPS } from '../lib/next-step.mjs';
30
30
  import { nextPrStep, reviewVerdict } from '../lib/pr-step.mjs';
31
31
  import { configuredMode } from '../lib/execution-mode.mjs';
32
32
  import { labelNames } from '../config.mjs';
@@ -41,8 +41,44 @@ const LOCK_STALE_MS = 30 * 60 * 1000;
41
41
  // comentário de uso. Fica em .git/ porque já é ignorado e é por clone.
42
42
  // ---------------------------------------------------------------------------
43
43
 
44
- function lockPath(root, key) {
45
- return path.join(root || process.cwd(), '.git', 'spec-wave', `run-${key}.lock`);
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`);
46
82
  }
47
83
 
48
84
  function acquireLock(root, key) {
@@ -126,12 +162,34 @@ function localDocStates(issue, type, root) {
126
162
  * clone. É a diferença entre "ainda não foi gerado" e "foi gerado e você não
127
163
  * puxou" — a segunda leva a sobrescrever trabalho publicado.
128
164
  */
129
- async function probeRemote({ decision, docs, docPaths, token, owner, repo }) {
130
- const step = STEPS[decision.action];
131
- if (!step) return docs;
132
- const alvos = [step.writes, ...step.reads]
133
- .filter(Boolean)
134
- .filter(doc => docs[doc] === 'missing');
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 }) {
135
193
  if (alvos.length === 0) return docs;
136
194
 
137
195
  const atualizado = { ...docs };
@@ -180,6 +238,32 @@ function parityWarnings(issue, type) {
180
238
  // Despacho
181
239
  // ---------------------------------------------------------------------------
182
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
+
183
267
  async function dispatch(action, { issueNumber }) {
184
268
  switch (action) {
185
269
  case 'generate-spec': {
@@ -315,6 +399,9 @@ export async function run(issueArg, options = {}) {
315
399
  const exitCodeAntes = process.exitCode;
316
400
  try {
317
401
  let ultimaDecisao = null;
402
+ // `--json` acumula e imprime UMA vez no fim (ver renderRunJson).
403
+ const passos = [];
404
+ let ultimoTipo = null;
318
405
 
319
406
  for (let i = 0; i < teto; i++) {
320
407
  const issue = await getIssue(token, owner, repo, parseInt(issueNumber, 10));
@@ -342,9 +429,12 @@ export async function run(issueArg, options = {}) {
342
429
  };
343
430
 
344
431
  let decision = nextStep({ ...entrada, docs });
345
- if (remoteCheck && decision.action !== 'none' && !decision.blocked) {
346
- const docsRemotos = await probeRemote({ decision, docs, docPaths, token, owner, repo });
347
- decision = nextStep({ ...entrada, docs: docsRemotos });
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
+ }
348
438
  }
349
439
  ultimaDecisao = decision;
350
440
 
@@ -357,7 +447,8 @@ export async function run(issueArg, options = {}) {
357
447
  );
358
448
  }
359
449
 
360
- if (json) console.log(JSON.stringify({ issue: Number(issueNumber), type, ...decision }, null, 2));
450
+ ultimoTipo = type;
451
+ if (json) passos.push(decision);
361
452
  else reportDecision(decision, { issueNumber, type, title: issue.title, warnings: parityWarnings(issue, type) });
362
453
 
363
454
  if (decision.blocked) { process.exitCode = 2; break; }
@@ -389,6 +480,10 @@ export async function run(issueArg, options = {}) {
389
480
  }
390
481
  }
391
482
 
483
+ if (json) {
484
+ console.log(renderRunJson({ issueNumber, type: ultimoTipo, ultimaDecisao, passos }));
485
+ }
486
+
392
487
  return ultimaDecisao;
393
488
  } finally {
394
489
  releaseLock(lock);
@@ -49,6 +49,28 @@ export function variableValueFor(mode) {
49
49
  return mode === 'local' ? 'local' : null;
50
50
  }
51
51
 
52
+ /**
53
+ * A variável do repositório precisa ser escrita? (função PURA)
54
+ *
55
+ * Três estados entram, não dois: `string` (valor lido), `null` (lida, e não
56
+ * existe) e `undefined` (NÃO deu para ler — 403, a variável exige admin).
57
+ *
58
+ * `undefined` conta como "precisa escrever". Tratá-lo como "já está certo"
59
+ * fazia o comando pular a escrita e ainda anunciar que config e variável
60
+ * coincidiam — o usuário saía achando que desligou o CI, com os workflows
61
+ * armados. Não conseguir ler quase sempre significa não conseguir escrever;
62
+ * tentar e falhar com a mensagem certa é honesto, não tentar e declarar
63
+ * sucesso não é.
64
+ *
65
+ * @param {object} params
66
+ * @param {string|null|undefined} params.variable valor remoto
67
+ * @param {string|null} params.expected valor que o modo-alvo exige (null = ausente)
68
+ * @returns {boolean}
69
+ */
70
+ export function shouldWriteVariable({ variable, expected }) {
71
+ return variable !== expected;
72
+ }
73
+
52
74
  /**
53
75
  * Diagnóstico do modo de execução (função PURA).
54
76
  *
@@ -44,11 +44,21 @@ export const STEPS = {
44
44
  types: ['Feature'], creates: false, ai: true,
45
45
  },
46
46
  validate: {
47
+ // `reads` DEPENDE DO TIPO: a validação de Feature abre spec.md e plan.md, a
48
+ // de Bug abre bug.md. Declarar `[]` aqui desarmava o G6 justamente para o
49
+ // passo que só lê do disco — um clone atrasado reprovava uma Feature cujo
50
+ // plan.md existia no remoto, removia `spec-wave:ready` e comentava a falha
51
+ // na issue. Dano a estado COMPARTILHADO por uma condição puramente local.
47
52
  trigger: LABEL_READY, cli: 'validate', writes: null, reads: [],
53
+ readsByType: { Feature: ['spec', 'plan'], Bug: ['bug'] },
48
54
  types: ['Feature', 'Bug'], creates: false, ai: false,
49
55
  },
50
56
  decompose: {
57
+ // O rascunho de Feature é montado a partir de spec.md + plan.md; o de RFC
58
+ // não usa nenhum dos dois. Com `[]` e o plan só no remoto, o decompose
59
+ // gerava o rascunho com o plano VAZIO — sem erro, só pior.
51
60
  trigger: LABEL_DECOMPOSE, cli: 'decompose', writes: 'decomposition', reads: [],
61
+ readsByType: { Feature: ['spec', 'plan'], RFC: [] },
52
62
  types: ['Feature', 'RFC'], creates: false, ai: true,
53
63
  },
54
64
  'decompose-apply': {
@@ -71,13 +81,60 @@ export const STEPS = {
71
81
  },
72
82
  };
73
83
 
84
+ /**
85
+ * Documentos que um passo LÊ, para um tipo de item (função PURA).
86
+ *
87
+ * Existe porque dois passos leem coisas diferentes conforme o tipo — `validate`
88
+ * abre spec+plan numa Feature e bug.md num Bug — e o G6 precisa da lista CERTA:
89
+ * ele é o que impede o passo de rodar contra um clone atrasado. Uma lista
90
+ * subdeclarada não causa erro visível, causa o passo rodando com o arquivo
91
+ * errado (ou ausente), que é o modo de falha caro.
92
+ *
93
+ * @param {string} action nome da ação em STEPS
94
+ * @param {string|null} [type] tipo do work item
95
+ * @returns {string[]} documentos lidos
96
+ */
97
+ export function stepReads(action, type) {
98
+ const step = STEPS[action];
99
+ if (!step) return [];
100
+ return step.readsByType?.[type] ?? step.reads;
101
+ }
102
+
103
+ /**
104
+ * Documentos que um passo TOCA (lê ou escreve), para um tipo (função PURA).
105
+ *
106
+ * É a lista que o G6, o aviso de "não deu para consultar o remoto" e a sonda do
107
+ * `run` precisam — os três erravam junto quando `reads` estava subdeclarado.
108
+ *
109
+ * @param {string} action
110
+ * @param {string|null} [type]
111
+ * @returns {string[]}
112
+ */
113
+ export function stepDocs(action, type) {
114
+ return [STEPS[action]?.writes, ...stepReads(action, type)].filter(Boolean);
115
+ }
116
+
117
+ /**
118
+ * Documentos que um tipo usa (função PURA).
119
+ *
120
+ * O `run` precisa disto para sondar o remoto quando a decisão veio BLOQUEADA e
121
+ * o passo é `none` — aí não há `writes`/`reads` de onde tirar a lista, e é
122
+ * justamente o caso em que o G5 afirma "não está no clone nem no remoto".
123
+ *
124
+ * @param {string|null} [type]
125
+ * @returns {string[]}
126
+ */
127
+ export function docsForType(type) {
128
+ return DOCS_BY_TYPE[type] || [];
129
+ }
130
+
74
131
  /** Tipos que o `run` sabe conduzir. Story/Task/Epic/Spike não têm passo de documento. */
75
132
  export const RUNNABLE_TYPES = ['Feature', 'Bug', 'RFC'];
76
133
 
77
134
  const TRIGGERS = Object.values(STEPS).map(s => s.trigger).filter(Boolean);
78
135
 
79
136
  /** Documentos que cada tipo usa, na ordem em que o fluxo os produz. */
80
- const DOCS_BY_TYPE = {
137
+ export const DOCS_BY_TYPE = {
81
138
  Feature: ['spec', 'plan', 'decomposition'],
82
139
  RFC: ['decomposition'],
83
140
  Bug: ['bug'],
@@ -244,7 +301,7 @@ export function nextStep({
244
301
 
245
302
  // G6 — documento que o passo lê ou escreve existe só no remoto. Gerar por cima
246
303
  // levaria o `pull --rebase` do commit a brigar com o documento bom.
247
- for (const doc of [step.writes, ...step.reads].filter(Boolean)) {
304
+ for (const doc of stepDocs(action, type)) {
248
305
  if (docState(docs, doc) === 'remote') {
249
306
  return decided(action, `\`${pathOf(doc)}\` existe no repositório mas não no seu clone.`, params,
250
307
  block('stale-checkout',
@@ -285,8 +342,7 @@ export function nextStep({
285
342
  'Confirme com `--yes` se é isso mesmo.'));
286
343
  }
287
344
 
288
- const incertos = [step.writes, ...step.reads]
289
- .filter(Boolean)
345
+ const incertos = stepDocs(action, type)
290
346
  .filter(doc => docState(docs, doc) === 'unknown');
291
347
  const aviso = incertos.length > 0
292
348
  ? ` (não foi possível consultar o remoto por ${incertos.map(pathOf).join(', ')} — ` +
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "spec-wave",
3
3
  "displayName": "Spec Wave",
4
- "version": "0.25.0",
4
+ "version": "0.26.0",
5
5
  "description": "Fluxo spec-driven no GitHub (RFC-001): Projects v2, labels de gatilho, spec/plan gerados por Action, decomposição em duas etapas e implementação orientada a Stories/Tasks.",
6
6
  "author": {
7
7
  "name": "Astratech",
@@ -26,7 +26,7 @@ npx @spec-wave/cli@latest mode actions # volta tudo para o CI
26
26
  npx @spec-wave/cli@latest run <issue> --dry-run
27
27
  ```
28
28
  2. Mostre ao usuário o passo, o motivo e o comando que rodaria. **Só então** execute sem a flag.
29
- 3. `--json` devolve a mesma decisão em JSON, quando você precisar ramificar programaticamente.
29
+ 3. `--json` devolve a decisão em JSON, quando você precisar ramificar programaticamente. É **um** documento: o desfecho no topo (`action`, `command`, `blocked`) e a sequência inteira em `steps` — útil com `--max-steps`.
30
30
 
31
31
  ## O que o `run` decide sozinho
32
32