@spec-wave/cli 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +109 -5
  2. package/bin/spec-wave.mjs +35 -2
  3. package/package.json +1 -1
  4. package/src/agent/anthropic-agent.mjs +40 -19
  5. package/src/agent/index.mjs +104 -38
  6. package/src/api/github-rest.mjs +63 -1
  7. package/src/commands/decompose.mjs +3 -10
  8. package/src/commands/doctor.mjs +70 -12
  9. package/src/commands/generate-bug.mjs +8 -14
  10. package/src/commands/generate-plan.mjs +2 -1
  11. package/src/commands/generate-spec.mjs +2 -1
  12. package/src/commands/init.mjs +5 -0
  13. package/src/commands/mode.mjs +169 -0
  14. package/src/commands/run.mjs +396 -0
  15. package/src/commands/validate.mjs +22 -6
  16. package/src/config.mjs +40 -6
  17. package/src/lib/claude.mjs +13 -3
  18. package/src/lib/config-file.mjs +62 -0
  19. package/src/lib/doc-paths.mjs +51 -0
  20. package/src/lib/execution-mode.mjs +110 -0
  21. package/src/lib/next-step.mjs +356 -0
  22. package/src/lib/pr-step.mjs +102 -0
  23. package/src/lib/repo-links.mjs +84 -0
  24. package/src/plugin/.claude-plugin/plugin.json +1 -1
  25. package/src/plugin/skills/doctor/SKILL.md +2 -1
  26. package/src/plugin/skills/run/SKILL.md +76 -0
  27. package/src/plugin/skills/setup/SKILL.md +1 -1
  28. package/src/plugin/skills/spec/SKILL.md +1 -1
  29. package/src/templates/skill/SKILL.md +25 -2
  30. package/src/templates/workflows/code-review.yml +5 -1
  31. package/src/templates/workflows/critique.yml +5 -0
  32. package/src/templates/workflows/decompose.yml +5 -0
  33. package/src/templates/workflows/generate-bug.yml +5 -0
  34. package/src/templates/workflows/generate-plan.yml +5 -0
  35. package/src/templates/workflows/generate-spec.yml +5 -0
  36. package/src/templates/workflows/qa.yml +6 -1
  37. package/src/templates/workflows/validate.yml +4 -0
@@ -13,13 +13,16 @@ import {
13
13
  tokenMismatchWarning, parseActiveAccount,
14
14
  } from '../api/auth.mjs';
15
15
  import { getProjectSnapshot, listSubIssues } from '../api/github-graphql.mjs';
16
+ import { getRepoVariable } from '../api/github-rest.mjs';
16
17
  import {
17
- CONFIG_FILE, WORKFLOW_FILES, getProvider, DEFAULT_PROVIDER, STATUS_OPTIONS,
18
+ CONFIG_FILE, WORKFLOW_FILES, getProvider, DEFAULT_PROVIDER, AI_PROVIDERS, STATUS_OPTIONS,
18
19
  RETIRED_STAGES, ALL_LABELS, allLabelsFor, LABEL_NEEDS_HUMAN, MODEL_LABEL_PREFIX,
19
20
  DEFAULT_MAX_CRITIQUE_ATTEMPTS, STAGE_TRACKS, AI_ACTIONS, recommendedModelAliases,
20
21
  modelLabels,
21
22
  } from '../config.mjs';
22
23
  import { findConfigPath } from '../lib/project-root.mjs';
24
+ import { configuredMode, describeModeState, EXECUTION_VARIABLE } from '../lib/execution-mode.mjs';
25
+ import { unguardedWorkflows } from './mode.mjs';
23
26
  import {
24
27
  DEFAULT_MAX_TOKENS, supportsStrictSchema, resolveAiConfig,
25
28
  } from '../lib/claude.mjs';
@@ -679,8 +682,9 @@ async function checkAi(ctx) {
679
682
  `${Object.entries(aliases).map(([a, m]) => `${a}=${m}`).join(', ')}.`
680
683
  );
681
684
  // Slug da OpenRouter tem "/" (anthropic/claude-…); id da Anthropic, não.
685
+ // O critério é o BACKEND: `claude-oauth` fala com a mesma API do `anthropic`.
682
686
  const wrongShape = Object.entries(aliases).filter(([, m]) => (
683
- provider.value === 'openrouter' ? !String(m).includes('/') : String(m).includes('/')
687
+ provider.backend === 'openrouter' ? !String(m).includes('/') : String(m).includes('/')
684
688
  ));
685
689
  if (wrongShape.length > 0) {
686
690
  status = 'warn';
@@ -700,7 +704,7 @@ async function checkAi(ctx) {
700
704
 
701
705
  // Saída estruturada da crítica: sem structured output confiável, a validação
702
706
  // do schema queima os retries antes de falhar.
703
- if (provider.value === 'openrouter' && !fileAi.models?.critique) {
707
+ if (provider.backend === 'openrouter' && !fileAi.models?.critique) {
704
708
  status = 'warn';
705
709
  notes.push(
706
710
  `Saída estruturada da crítica: o provider é openrouter e \`ai.models.critique\` não está ` +
@@ -708,21 +712,27 @@ async function checkAi(ctx) {
708
712
  'e a crítica falha na primeira execução. Aponte `ai.models.critique` para um modelo com ' +
709
713
  'saída estruturada.'
710
714
  );
715
+ } else if (provider.backend === 'anthropic') {
716
+ // No backend anthropic quem garante o schema é o próprio Claude Code
717
+ // (`outputFormat: json_schema`), repetindo o turno até casar — não há
718
+ // tool call forçado nem `strict` de endpoint para reportar.
719
+ notes.push(
720
+ `Saída estruturada da crítica: json_schema do Claude Code (${provider.value}) ` +
721
+ '— o CLI repete o turno até a resposta casar com o schema.'
722
+ );
711
723
  } else {
712
724
  notes.push(
713
725
  `Saída estruturada da crítica: tool call forçado (${provider.value}) ` +
714
726
  `· strict=${supportsStrictSchema(critiqueModel) ? 'sim' : 'não'} neste modelo.`
715
727
  );
716
728
  }
717
- // O backend anthropic não chama a API — sobe o Claude Code CLI como
718
- // subprocesso, que não existe no runner dos workflows (só setup-node).
719
- if (provider.value === 'anthropic') {
720
- problems.push(
721
- 'O provider `anthropic` NÃO roda nos GitHub Actions: ele sobe o Claude Code CLI como ' +
722
- 'subprocesso, ausente no runner. As Actions de spec/plan/decompose vão falhar. ' +
723
- 'Troque para `"provider": "openrouter"` no .spec-wave.json (e adicione o secret ' +
724
- 'OPENROUTER_API_KEY), ou instale o Claude Code no workflow e defina ' +
725
- 'SPEC_WAVE_ALLOW_ANTHROPIC_IN_CI=1. Localmente o anthropic funciona.'
729
+ // O backend anthropic sobe o Claude Code CLI como subprocesso — e o binário
730
+ // vem embarcado no SDK, então o runner não precisa de instalação extra.
731
+ if (provider.backend === 'anthropic') {
732
+ notes.push(
733
+ `Backend anthropic (${provider.value}): o Claude Code sobe como subprocesso a partir do ` +
734
+ 'binário embarcado no @anthropic-ai/claude-agent-sdk nada a instalar no runner além do ' +
735
+ 'próprio CLI. falha se o pacote for instalado com `--omit=optional`.'
726
736
  );
727
737
  }
728
738
 
@@ -929,6 +939,34 @@ export function checkSpecKit(ctx) {
929
939
  };
930
940
  }
931
941
 
942
+ // Modo de execução: o config e a variável do repositório precisam concordar.
943
+ // Discordar não é detalhe — é o usuário achando que desligou os workflows e
944
+ // continuando a pagar minutos (ou o contrário: tudo pulado e nada rodando).
945
+ async function checkExecutionMode(ctx) {
946
+ const name = 'Modo de execução (Actions × local)';
947
+ const configured = configuredMode(ctx.cfg);
948
+
949
+ let variable; // undefined = não verificável
950
+ if (ctx.token && ctx.cfg?.owner && ctx.cfg?.repo) {
951
+ try {
952
+ variable = await getRepoVariable(ctx.token, ctx.cfg.owner, ctx.cfg.repo, EXECUTION_VARIABLE);
953
+ } catch (err) {
954
+ variable = undefined;
955
+ if (err.status !== 403 && err.status !== 404) {
956
+ return { name, status: 'warn', detail: `Variável não verificável agora: ${err.message}` };
957
+ }
958
+ }
959
+ }
960
+
961
+ const estado = describeModeState({
962
+ configured,
963
+ variable,
964
+ unguardedWorkflows: unguardedWorkflows(ctx.root || ctx.cwd),
965
+ });
966
+ const status = estado.status === 'problem' ? 'fail' : estado.status;
967
+ return { name, status, detail: [estado.summary, ...estado.notes, ...estado.fixes].join('\n') };
968
+ }
969
+
932
970
  async function checkWorkflows(ctx) {
933
971
  const name = 'Workflows do Actions';
934
972
  // Ancorado na raiz do projeto, não no cwd: rodar o doctor de um subdiretório
@@ -955,10 +993,19 @@ async function checkWorkflows(ctx) {
955
993
  // comportamento de pipelines já em andamento.
956
994
  const unpinned = [];
957
995
  const otherVersion = [];
996
+ // O secret do provider escolhido chega ao runner? A YAML é que decide: um
997
+ // repo configurado com um provider cujo secret o workflow não encaminha morre
998
+ // com "Faltam credenciais" no meio do fluxo, e o único conserto é `update`.
999
+ const provider = getProvider(ctx.cfg?.ai?.provider) || getProvider(DEFAULT_PROVIDER);
1000
+ const missingSecret = [];
958
1001
  for (const file of WORKFLOW_FILES) {
959
1002
  const content = readFileSync(path.join(dir, file), 'utf-8');
960
1003
  if (/@spec-wave\/cli@latest/.test(content)) unpinned.push(file);
961
1004
  else if (!content.includes(`@spec-wave/cli@${CLI_VERSION}`)) otherVersion.push(file);
1005
+ // Identifica os workflows de IA pelo que eles próprios declaram, em vez de
1006
+ // manter uma segunda lista para sair de sincronia com WORKFLOW_FILES.
1007
+ const usesAi = AI_PROVIDERS.some(pr => content.includes(`secrets.${pr.secret}`));
1008
+ if (usesAi && !content.includes(`secrets.${provider.secret}`)) missingSecret.push(file);
962
1009
  }
963
1010
  const notes = [`Os ${WORKFLOW_FILES.length} workflows do spec-wave estão presentes.`];
964
1011
  let status = 'ok';
@@ -976,6 +1023,16 @@ async function checkWorkflows(ctx) {
976
1023
  'para fazer o bump explícito.'
977
1024
  );
978
1025
  }
1026
+ if (missingSecret.length > 0) {
1027
+ status = 'fail';
1028
+ notes.push(
1029
+ `Não encaminham \`${provider.secret}\` (secret do provider ${provider.value}): ` +
1030
+ `${missingSecret.join(', ')}. Esses workflows vão falhar com "Faltam credenciais" — ` +
1031
+ 'rode `npx @spec-wave/cli@latest update`.'
1032
+ );
1033
+ } else {
1034
+ notes.push(`Todos encaminham \`${provider.secret}\` (provider ${provider.value}).`);
1035
+ }
979
1036
  return { name, status, detail: notes.join('\n') };
980
1037
  }
981
1038
 
@@ -1011,6 +1068,7 @@ export async function doctor() {
1011
1068
  checkDecompositions,
1012
1069
  checkSpecKit,
1013
1070
  checkWorkflows,
1071
+ checkExecutionMode,
1014
1072
  ];
1015
1073
  const results = [];
1016
1074
  const spinner = p.spinner();
@@ -15,11 +15,11 @@ import {
15
15
  import { generateDocument } from '../lib/claude.mjs';
16
16
  import { unwrapGeneratedDoc } from '../lib/unwrap-doc.mjs';
17
17
  import { recordUsage } from '../lib/usage-report.mjs';
18
- import { loadConfig } from '../lib/project-root.mjs';
19
18
  import { loadPrompt, systemPromptWithTools } from '../lib/prompt-loader.mjs';
20
19
  import { detectIssueType } from '../lib/issue-type.mjs';
21
20
  import { bugDocPaths } from '../lib/bug-doc.mjs';
22
- import { commitGenerated, executionMode } from '../lib/flow-run.mjs';
21
+ import { commitGenerated, resolveFlowContext } from '../lib/flow-run.mjs';
22
+ import { docBlobUrl } from '../lib/repo-links.mjs';
23
23
  import {
24
24
  runCritique, resolveCritiqueAttempt, renderNeedsHumanComment,
25
25
  } from '../lib/critique.mjs';
@@ -48,18 +48,12 @@ function isSpecWaveComment(body) {
48
48
 
49
49
  export async function generateBug({ issueNumber }) {
50
50
  const token = await resolveToken();
51
- const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/');
52
- const { root } = loadConfig();
51
+ // Mesmo contexto dos outros geradores: GITHUB_REPOSITORY quando existe, o
52
+ // `.spec-wave.json` quando não. Este comando exigia a env crua e morria fora
53
+ // do runner mandando exportá-la — dentro de um repo que já sabe seu owner/repo.
54
+ const { owner, repo, root, config, mode } = resolveFlowContext({ command: 'generate-bug' });
53
55
  const n = parseInt(issueNumber, 10);
54
56
 
55
- if (!owner || !repo) {
56
- throw new Error(
57
- 'GITHUB_REPOSITORY env var não definida.\n' +
58
- 'Este comando roda no GitHub Actions. Para testar localmente:\n' +
59
- ' GITHUB_REPOSITORY=owner/repo spec-wave generate-bug --issue-number 1'
60
- );
61
- }
62
-
63
57
  console.log(`Buscando issue #${n}...`);
64
58
  const issue = await getIssue(token, owner, repo, n);
65
59
 
@@ -126,7 +120,7 @@ export async function generateBug({ issueNumber }) {
126
120
  filePath: fileAbs,
127
121
  content,
128
122
  message: `docs: generate bug.md for ${slug} [spec-wave]`,
129
- mode: executionMode(),
123
+ mode,
130
124
  });
131
125
  if (published.warning) console.warn(`⚠️ ${published.warning}`);
132
126
 
@@ -142,7 +136,7 @@ export async function generateBug({ issueNumber }) {
142
136
  await commentOnIssue(
143
137
  token, owner, repo, n,
144
138
  '🐞 **bug.md gerado automaticamente!**\n\n' +
145
- `📄 Arquivo: [\`${fileRel}\`](https://github.com/${owner}/${repo}/blob/main/${fileRel})\n\n` +
139
+ `📄 Arquivo: [\`${fileRel}\`](${docBlobUrl({ owner, repo, pathRel: fileRel, mode, root, config })})\n\n` +
146
140
  'Revise a **causa raiz** e o **teste de regressão** — são as duas seções que decidem se ' +
147
141
  'a correção ataca o defeito ou o sintoma. Quando estiver pronto, valide com:\n' +
148
142
  `\`\`\`\ngh issue edit ${n} --add-label "spec-wave:ready"\n\`\`\`` +
@@ -20,6 +20,7 @@ import { recordUsage } from '../lib/usage-report.mjs';
20
20
  import { slugify } from '../lib/slugify.mjs';
21
21
  import { resolveFromRoot } from '../lib/project-root.mjs';
22
22
  import { resolveFlowContext, commitGenerated } from '../lib/flow-run.mjs';
23
+ import { docBlobUrl } from '../lib/repo-links.mjs';
23
24
  import { buildTechContext } from '../lib/tech-context.mjs';
24
25
  import { loadPrompt, systemPromptWithTools } from '../lib/prompt-loader.mjs';
25
26
 
@@ -213,7 +214,7 @@ export async function generatePlan({ issueNumber }) {
213
214
  await commentOnIssue(
214
215
  token, owner, repo, parseInt(issueNumber, 10),
215
216
  `📋 **plan.md gerado automaticamente!**\n\n` +
216
- `📄 Arquivo: [\`${fileRel}\`](https://github.com/${owner}/${repo}/blob/main/${fileRel})\n\n` +
217
+ `📄 Arquivo: [\`${fileRel}\`](${docBlobUrl({ owner, repo, pathRel: fileRel, mode, root, config })})\n\n` +
217
218
  `Revise o plano e, quando estiver pronto, valide a Feature: mova o card para **✅ Ready** ou use:\n` +
218
219
  `\`\`\`\ngh issue edit ${issueNumber} --add-label "spec-wave:ready"\n\`\`\`` +
219
220
  formatLintWarning(lintFindings)
@@ -7,6 +7,7 @@ import { recordUsage } from '../lib/usage-report.mjs';
7
7
  import { slugify } from '../lib/slugify.mjs';
8
8
  import { resolveFromRoot } from '../lib/project-root.mjs';
9
9
  import { resolveFlowContext, commitGenerated } from '../lib/flow-run.mjs';
10
+ import { docBlobUrl } from '../lib/repo-links.mjs';
10
11
  import { loadPrompt, systemPromptWithTools } from '../lib/prompt-loader.mjs';
11
12
  import { detectIssueType } from '../lib/issue-type.mjs';
12
13
  import {
@@ -110,7 +111,7 @@ export async function generateSpec({ issueNumber }) {
110
111
  await commentOnIssue(
111
112
  token, owner, repo, parseInt(issueNumber, 10),
112
113
  `📋 **spec.md gerado automaticamente!**\n\n` +
113
- `📄 Arquivo: [\`${fileRel}\`](https://github.com/${owner}/${repo}/blob/main/${fileRel})\n\n` +
114
+ `📄 Arquivo: [\`${fileRel}\`](${docBlobUrl({ owner, repo, pathRel: fileRel, mode, root })})\n\n` +
114
115
  `Revise a especificação e, quando estiver pronto, gere o plano técnico: mova o card para **📋 Plan** ou use:\n` +
115
116
  `\`\`\`\ngh issue edit ${issueNumber} --add-label "spec-wave:plan"\n\`\`\`` +
116
117
  formatLintWarning(lintFindings)
@@ -220,6 +220,11 @@ export async function init(options) {
220
220
  ? ` 1. Commite o ${CONFIG_FILE} quando quiser (git add ${CONFIG_FILE} && git commit)\n`
221
221
  : '') +
222
222
  ` ${configWritten ? '2' : '1'}. Adicione ${providerMeta.secret} como secret no repositório (provider: ${providerMeta.label})\n` +
223
+ // O valor do token da assinatura não é copiável de lugar nenhum: só existe
224
+ // depois de rodar o comando que o gera.
225
+ (providerMeta.secret === 'CLAUDE_CODE_OAUTH_TOKEN'
226
+ ? ` ${chalk.dim('Gere o valor com: claude setup-token')}\n`
227
+ : '') +
223
228
  ` ${configWritten ? '3' : '2'}. Configure o board view para agrupar por "Etapa"\n` +
224
229
  ` ${configWritten ? '4' : '3'}. Crie uma Feature com o prefixo [FEATURE] no título\n` +
225
230
  ` ${configWritten ? '5' : '4'}. Use a skill spec-wave para guiar o fluxo\n\n` +
@@ -0,0 +1,169 @@
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,
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
+ const mudaVariavel = variavel !== undefined && variavel !== esperado;
104
+
105
+ if (!mudaConfig && !mudaVariavel) {
106
+ p.log.success(`Já está em ${chalk.bold(alvo)} — config e variável do repositório coincidem.`);
107
+ p.outro('Nada a fazer.');
108
+ return { mode: alvo, variable: variavel, changed: false };
109
+ }
110
+
111
+ if (dryRun) {
112
+ if (mudaConfig) p.log.info(`${CONFIG_FILE}: execution.mode ${atual} → ${alvo}`);
113
+ if (mudaVariavel) {
114
+ p.log.info(esperado === null
115
+ ? `Variável ${EXECUTION_VARIABLE}: remover (valor atual: ${variavel})`
116
+ : `Variável ${EXECUTION_VARIABLE}: definir como "${esperado}"`);
117
+ }
118
+ p.outro('Dry-run: nada foi alterado.');
119
+ return { mode: atual, variable: variavel, changed: false };
120
+ }
121
+
122
+ if (mudaConfig) {
123
+ const { changed } = updateConfig(cfg => {
124
+ cfg.execution = { ...(cfg.execution || {}), mode: alvo };
125
+ }, { cwd: root || process.cwd() });
126
+ if (changed) p.log.success(`${CONFIG_FILE} atualizado: execution.mode = ${alvo}`);
127
+ }
128
+
129
+ let variavelOk = true;
130
+ if (token && owner && repo) {
131
+ try {
132
+ if (esperado === null) {
133
+ const removida = await deleteRepoVariable(token, owner, repo, EXECUTION_VARIABLE);
134
+ p.log.success(removida
135
+ ? `Variável ${EXECUTION_VARIABLE} removida — os workflows voltam a disparar.`
136
+ : `Variável ${EXECUTION_VARIABLE} já não existia.`);
137
+ } else {
138
+ await setRepoVariable(token, owner, repo, EXECUTION_VARIABLE, esperado);
139
+ p.log.success(`Variável ${EXECUTION_VARIABLE}=${esperado} — os jobs passam a ser pulados (0 minutos).`);
140
+ }
141
+ } catch (err) {
142
+ variavelOk = false;
143
+ p.log.error(
144
+ `Não foi possível escrever a variável ${EXECUTION_VARIABLE} (${err.status || ''} ${err.message}).\n` +
145
+ 'Ela exige administração no repositório — peça a um admin, ou defina em ' +
146
+ 'Settings → Secrets and variables → Actions → Variables.'
147
+ );
148
+ }
149
+ } else {
150
+ variavelOk = false;
151
+ p.log.warn(`Sem owner/repo ou token: só o ${CONFIG_FILE} foi atualizado.`);
152
+ }
153
+
154
+ const semGuarda = unguardedWorkflows(root);
155
+ if (alvo === 'local' && semGuarda.length > 0) {
156
+ p.log.warn(
157
+ `Estes workflows instalados ainda não têm a guarda \`${EXECUTION_GUARD}\` e vão rodar mesmo assim: ` +
158
+ `${semGuarda.join(', ')}. Rode \`npx @spec-wave/cli@latest update\`.`
159
+ );
160
+ }
161
+
162
+ p.log.message(chalk.dim(`Commite o ${configPath ? path.basename(configPath) : CONFIG_FILE} — quem clona o repo lê a versão versionada.`));
163
+ p.outro(
164
+ alvo === 'local'
165
+ ? `Modo local. Conduza o fluxo com ${chalk.cyan('spec-wave run <issue>')}.`
166
+ : 'Modo actions. As labels de gatilho voltam a disparar os workflows.'
167
+ );
168
+ return { mode: alvo, variable: esperado, changed: true, variableApplied: variavelOk };
169
+ }