@spec-wave/cli 0.16.0 → 0.16.1

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
@@ -163,7 +163,7 @@ program
163
163
 
164
164
  program
165
165
  .command('generate-plan')
166
- .description('Gera plan.md para uma Feature (usado pelo GitHub Action)')
166
+ .description('Gera plan.md para uma Feature roda no GitHub Action ou localmente')
167
167
  .requiredOption('--issue-number <n>', 'Número da issue no GitHub')
168
168
  .action(async (options) => {
169
169
  const { generatePlan } = await import('../src/commands/generate-plan.mjs');
@@ -172,7 +172,7 @@ program
172
172
 
173
173
  program
174
174
  .command('generate-spec')
175
- .description('Gera spec.md para uma Feature (usado pelo GitHub Action)')
175
+ .description('Gera spec.md para uma Feature roda no GitHub Action ou localmente')
176
176
  .requiredOption('--issue-number <n>', 'Número da issue no GitHub')
177
177
  .action(async (options) => {
178
178
  const { generateSpec } = await import('../src/commands/generate-spec.mjs');
@@ -199,7 +199,7 @@ program
199
199
 
200
200
  program
201
201
  .command('decompose')
202
- .description('Gera o rascunho da decomposição em decomposition.md; com --apply, cria as Stories/Tasks a partir do rascunho revisado (usado pelo GitHub Action)')
202
+ .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')
203
203
  .requiredOption('--issue-number <n>', 'Número da issue no GitHub')
204
204
  .option('--apply', 'Aplica o decomposition.md já revisado: cria as issues (sem esta flag, apenas gera/critica o rascunho)')
205
205
  .action(async (options) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spec-wave/cli",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
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": {
@@ -33,6 +33,23 @@ export async function createProject(token, ownerId, title) {
33
33
  return { projectId: project.id, projectNumber: project.number, projectUrl: project.url, statusFieldId: statusField?.id };
34
34
  }
35
35
 
36
+ /**
37
+ * Reescreve as opções de um campo single-select.
38
+ *
39
+ * ⚠️ O `id` de cada opção é OBRIGATÓRIO para as que já existem. Sem ele o
40
+ * GitHub CRIA uma opção nova com o mesmo nome e id diferente — e todo item do
41
+ * board que apontava para a opção antiga fica SEM VALOR, em silêncio.
42
+ *
43
+ * Foi exatamente o que aconteceu no smoke test do RFC-004: as 11 etapas
44
+ * existentes foram preservadas por NOME, os ids mudaram todos, e a Feature que
45
+ * estava em 🎯 Priorizado apareceu sem Etapa. Preservar o nome não preserva
46
+ * nada — o que liga um item à opção é o id.
47
+ *
48
+ * @param {string} token
49
+ * @param {string} fieldId
50
+ * @param {Array<{name: string, color: string, id?: string}>} options
51
+ * Opções na ordem final. Com `id` = preserva a existente; sem `id` = cria.
52
+ */
36
53
  export async function updateStatusField(token, fieldId, options) {
37
54
  const client = makeClient(token);
38
55
  await client(`
@@ -51,7 +68,12 @@ export async function updateStatusField(token, fieldId, options) {
51
68
  }
52
69
  `, {
53
70
  fieldId,
54
- options: options.map(o => ({ name: o.name, color: o.color, description: '' })),
71
+ options: options.map(o => ({
72
+ ...(o.id ? { id: o.id } : {}),
73
+ name: o.name,
74
+ color: o.color,
75
+ description: '',
76
+ })),
55
77
  });
56
78
  }
57
79
 
@@ -33,6 +33,7 @@ import { lintLanguage } from '../lib/output-lint.mjs';
33
33
  import { slugify } from '../lib/slugify.mjs';
34
34
  import { detectIssueType } from '../lib/issue-type.mjs';
35
35
  import { loadConfig } from '../lib/project-root.mjs';
36
+ import { resolveFlowContext, commitGenerated } from '../lib/flow-run.mjs';
36
37
  import { loadPrompt, toolFreeSystemPrompt } from '../lib/prompt-loader.mjs';
37
38
  import {
38
39
  renderDecompositionDoc, parseDecompositionDoc, DECOMPOSITION_FILE,
@@ -194,18 +195,11 @@ function formatItemsLintWarning(texts) {
194
195
  return `\n\n⚠️ possíveis artefatos de idioma nos itens gerados: ${excerpts}`;
195
196
  }
196
197
 
197
- // Grava e commita o rascunho mesmo padrão do generate-plan (o workflow roda
198
- // com contents: write e checkout com token).
199
- function commitFile(filePath, content, message) {
200
- mkdirSync(path.dirname(filePath), { recursive: true });
201
- writeFileSync(filePath, content, 'utf-8');
202
- const git = (cmd) => execSync(cmd, { stdio: 'inherit' });
203
- git('git config user.email "spec-wave[bot]@github.com"');
204
- git('git config user.name "spec-wave[bot]"');
205
- git(`git add "${filePath}"`);
206
- git(`git commit -m "${message}"`);
207
- git('git pull --rebase');
208
- git('git push');
198
+ // Grava e commita o rascunho. O modo (actions|local) decide identidade do git e
199
+ // o que fazer com falha de push ver `lib/flow-run.mjs`.
200
+ function commitFile(filePath, content, message, mode) {
201
+ const published = commitGenerated({ filePath, content, message, mode });
202
+ if (published.warning) console.warn(`⚠️ ${published.warning}`);
209
203
  }
210
204
 
211
205
  // Os prompts vivem em `src/plugin/skills/decompose/model-prompt.{feature,rfc}.md`
@@ -218,7 +212,7 @@ function commitFile(filePath, content, message) {
218
212
  // ---------------------------------------------------------------------------
219
213
 
220
214
  async function draftDecomposition(ctx) {
221
- const { token, owner, repo, issue, issueNumber, type, labels, usage, root, docDir, docPath, docRel } = ctx;
215
+ const { token, owner, repo, issue, issueNumber, type, labels, usage, root, runMode, docDir, docPath, docRel } = ctx;
222
216
  const number = parseInt(issueNumber, 10);
223
217
  const kind = DECOMPOSE_TARGETS[type]; // Feature → 'stories'; RFC → 'tasks'
224
218
  const blobUrl = `https://github.com/${owner}/${repo}/blob/main/${docRel}`;
@@ -267,7 +261,7 @@ async function draftDecomposition(ctx) {
267
261
  stories: generated.stories || [],
268
262
  tasks: generated.tasks || [],
269
263
  });
270
- commitFile(docPath, markdown, `docs: rascunho de decomposição de ${docRel} [spec-wave]`);
264
+ commitFile(docPath, markdown, `docs: rascunho de decomposição de ${docRel} [spec-wave]`, runMode);
271
265
  console.log(`Rascunho commitado em ${docRel}.`);
272
266
  }
273
267
 
@@ -589,15 +583,9 @@ export async function decompose({ issueNumber, apply = false }) {
589
583
  // PROJECT_TOKEN deve ter scope "project" para atualizar GitHub Projects v2.
590
584
  // Fallback para GITHUB_TOKEN (só funciona em repos pessoais sem org restrictions).
591
585
  const projectToken = process.env.PROJECT_TOKEN || token;
592
- const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/');
593
-
594
- if (!owner || !repo) {
595
- throw new Error(
596
- 'GITHUB_REPOSITORY env var não definida.\n' +
597
- 'Este comando roda no GitHub Actions. Para testar localmente:\n' +
598
- ' GITHUB_REPOSITORY=owner/repo spec-wave decompose --issue-number 1'
599
- );
600
- }
586
+ // Roda nos dois modos: no Action (disparado por label) e na sessão local.
587
+ const { owner, repo, mode: runMode } = resolveFlowContext({ command: 'decompose' });
588
+ console.log(`Modo de execução: ${runMode}`);
601
589
 
602
590
  const number = parseInt(issueNumber, 10);
603
591
  const issue = await getIssue(token, owner, repo, number);
@@ -669,7 +657,7 @@ export async function decompose({ issueNumber, apply = false }) {
669
657
  const usageEntries = [];
670
658
  const ctx = {
671
659
  token, projectToken, owner, repo, issue, issueNumber, type, labels, comments,
672
- root, docDir, docPath, docRel: `${docRel}/${DECOMPOSITION_FILE}`,
660
+ root, runMode, docDir, docPath, docRel: `${docRel}/${DECOMPOSITION_FILE}`,
673
661
  escalationModel: config?.ai?.escalationModel || null,
674
662
  maxCritiqueAttempts:
675
663
  Number.isInteger(config?.ai?.maxCritiqueAttempts) && config.ai.maxCritiqueAttempts > 0
@@ -1,6 +1,5 @@
1
- import { execSync } from 'node:child_process';
2
1
  import path from 'node:path';
3
- import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
2
+ import { readFileSync, existsSync } from 'node:fs';
4
3
  import { resolveToken } from '../api/auth.mjs';
5
4
  import {
6
5
  getIssue, removeLabel, addLabel, commentOnIssue, listIssueComments,
@@ -16,7 +15,8 @@ import {
16
15
  } from '../lib/critique.mjs';
17
16
  import { recordUsage } from '../lib/usage-report.mjs';
18
17
  import { slugify } from '../lib/slugify.mjs';
19
- import { loadConfig, resolveFromRoot } from '../lib/project-root.mjs';
18
+ import { resolveFromRoot } from '../lib/project-root.mjs';
19
+ import { resolveFlowContext, commitGenerated } from '../lib/flow-run.mjs';
20
20
  import { buildTechContext } from '../lib/tech-context.mjs';
21
21
  import { loadPrompt, toolFreeSystemPrompt } from '../lib/prompt-loader.mjs';
22
22
 
@@ -109,16 +109,9 @@ async function critiquePlan({
109
109
 
110
110
  export async function generatePlan({ issueNumber }) {
111
111
  const token = await resolveToken();
112
- const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/');
113
- const { config, root } = loadConfig();
114
-
115
- if (!owner || !repo) {
116
- throw new Error(
117
- 'GITHUB_REPOSITORY env var não definida.\n' +
118
- 'Este comando roda no GitHub Actions. Para testar localmente:\n' +
119
- ' GITHUB_REPOSITORY=owner/repo spec-wave generate-plan --issue-number 1'
120
- );
121
- }
112
+ // Roda nos dois modos: no Action (disparado por label) e na sessão local.
113
+ const { owner, repo, root, config, mode } = resolveFlowContext({ command: 'generate-plan' });
114
+ console.log(`Modo de execução: ${mode}`);
122
115
 
123
116
  console.log(`Buscando issue #${issueNumber}...`);
124
117
  const issue = await getIssue(token, owner, repo, parseInt(issueNumber, 10));
@@ -180,17 +173,13 @@ export async function generatePlan({ issueNumber }) {
180
173
  usage: usageEntries,
181
174
  });
182
175
 
183
- mkdirSync(featureDir, { recursive: true });
184
- writeFileSync(filePath, content, 'utf-8');
185
-
186
- // Commit and push
187
- const git = (cmd) => execSync(cmd, { stdio: 'inherit' });
188
- git(`git config user.email "spec-wave[bot]@github.com"`);
189
- git(`git config user.name "spec-wave[bot]"`);
190
- git(`git add "${filePath}"`);
191
- git(`git commit -m "docs: generate plan.md for ${slug} [spec-wave]"`);
192
- git('git pull --rebase');
193
- git('git push');
176
+ const published = commitGenerated({
177
+ filePath,
178
+ content,
179
+ message: `docs: generate plan.md for ${slug} [spec-wave]`,
180
+ mode,
181
+ });
182
+ if (published.warning) console.warn(`⚠️ ${published.warning}`);
194
183
 
195
184
  // Remove trigger label
196
185
  await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:plan');
@@ -1,12 +1,11 @@
1
- import { execSync } from 'node:child_process';
2
1
  import path from 'node:path';
3
- import { mkdirSync, writeFileSync } from 'node:fs';
4
2
  import { resolveToken } from '../api/auth.mjs';
5
3
  import { getIssue, removeLabel, commentOnIssue } from '../api/github-rest.mjs';
6
4
  import { generateDocument } from '../lib/claude.mjs';
7
5
  import { recordUsage } from '../lib/usage-report.mjs';
8
6
  import { slugify } from '../lib/slugify.mjs';
9
- import { loadConfig, resolveFromRoot } from '../lib/project-root.mjs';
7
+ import { resolveFromRoot } from '../lib/project-root.mjs';
8
+ import { resolveFlowContext, commitGenerated } from '../lib/flow-run.mjs';
10
9
  import { loadPrompt, toolFreeSystemPrompt } from '../lib/prompt-loader.mjs';
11
10
  import { detectIssueType } from '../lib/issue-type.mjs';
12
11
  import {
@@ -30,16 +29,9 @@ function formatLintWarning(lintFindings) {
30
29
 
31
30
  export async function generateSpec({ issueNumber }) {
32
31
  const token = await resolveToken();
33
- const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/');
34
- const { root } = loadConfig();
35
-
36
- if (!owner || !repo) {
37
- throw new Error(
38
- 'GITHUB_REPOSITORY env var não definida.\n' +
39
- 'Este comando roda no GitHub Actions. Para testar localmente:\n' +
40
- ' GITHUB_REPOSITORY=owner/repo spec-wave generate-spec --issue-number 1'
41
- );
42
- }
32
+ // Roda nos dois modos: no Action (disparado por label) e na sessão local.
33
+ const { owner, repo, root, mode } = resolveFlowContext({ command: 'generate-spec' });
34
+ console.log(`Modo de execução: ${mode}`);
43
35
 
44
36
  console.log(`Buscando issue #${issueNumber}...`);
45
37
  const issue = await getIssue(token, owner, repo, parseInt(issueNumber, 10));
@@ -95,17 +87,13 @@ export async function generateSpec({ issueNumber }) {
95
87
  usage: usageEntries,
96
88
  });
97
89
 
98
- mkdirSync(featureDir, { recursive: true });
99
- writeFileSync(filePath, content, 'utf-8');
100
-
101
- // Commit and push
102
- const git = (cmd) => execSync(cmd, { stdio: 'inherit' });
103
- git(`git config user.email "spec-wave[bot]@github.com"`);
104
- git(`git config user.name "spec-wave[bot]"`);
105
- git(`git add "${filePath}"`);
106
- git(`git commit -m "docs: generate spec.md for ${slug} [spec-wave]"`);
107
- git('git pull --rebase');
108
- git('git push');
90
+ const published = commitGenerated({
91
+ filePath,
92
+ content,
93
+ message: `docs: generate spec.md for ${slug} [spec-wave]`,
94
+ mode,
95
+ });
96
+ if (published.warning) console.warn(`⚠️ ${published.warning}`);
109
97
 
110
98
  // Remove trigger label
111
99
  await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:spec');
@@ -18,28 +18,38 @@ const pkg = JSON.parse(readFileSync(path.join(__dir, '..', '..', 'package.json')
18
18
  * board mais as etapas canônicas que faltam, cada uma na posição canônica. Nada
19
19
  * é removido — nem coluna inventada, nem etapa descontinuada.
20
20
  *
21
- * O motivo é o contrato da API: `updateProjectV2Field` SUBSTITUI o conjunto de
22
- * opções do single-select, e a preservação do valor de cada item do board
23
- * depende de a opção continuar existindo com o mesmo nome. Uma opção que some
24
- * leva junto a Etapa de todo item que estava nela — silenciosamente. Remover
25
- * coluna continua sendo trabalho manual, feito com os itens já esvaziados.
21
+ * ⚠️ E cada opção existente leva seu **id** junto. O contrato da API é o que
22
+ * torna isso obrigatório: `updateProjectV2Field` SUBSTITUI o conjunto de
23
+ * opções, e o que liga um item do board a uma opção é o ID — não o nome. Uma
24
+ * opção reenviada sem id é RECRIADA com id novo, e todo item que estava nela
25
+ * fica sem Etapa, em silêncio.
26
26
  *
27
- * @param {string[]} current nomes das opções hoje no board, na ordem atual
27
+ * A versão anterior preservava o nome. No smoke test do RFC-004 isso apagou
28
+ * a Etapa de uma Feature que estava em 🎯 Priorizado, enquanto o comando
29
+ * relatava "nenhuma é removida" — verdade sobre os nomes, mentira sobre o
30
+ * board.
31
+ *
32
+ * @param {Record<string, string>} current opções hoje no board: nome → id
28
33
  * @param {Array<{name: string, color: string}>} canonical STATUS_OPTIONS
29
- * @returns {{ missing: string[], preserved: string[], ordered: Array<{name: string, color: string}> }}
34
+ * @returns {{ missing: string[], preserved: string[],
35
+ * ordered: Array<{name: string, color: string, id?: string}> }}
30
36
  */
31
37
  export function planStageSync(current, canonical) {
32
- const have = new Set(current || []);
38
+ const atual = current || {};
39
+ const idDe = (name) => atual[name];
33
40
  const canonicalNames = new Set(canonical.map(o => o.name));
34
41
 
35
- const missing = canonical.filter(o => !have.has(o.name)).map(o => o.name);
42
+ const missing = canonical.filter(o => !idDe(o.name)).map(o => o.name);
36
43
  // Colunas que o board tem e o fluxo canônico não conhece (inventadas ou
37
44
  // descontinuadas). Vão para o fim, preservando a ordem relativa que tinham.
38
- const preserved = (current || []).filter(name => !canonicalNames.has(name));
45
+ const preserved = Object.keys(atual).filter(name => !canonicalNames.has(name));
39
46
 
40
47
  const ordered = [
41
- ...canonical.map(o => ({ name: o.name, color: o.color })),
42
- ...preserved.map(name => ({ name, color: 'GRAY' })),
48
+ ...canonical.map(o => {
49
+ const id = idDe(o.name);
50
+ return id ? { name: o.name, color: o.color, id } : { name: o.name, color: o.color };
51
+ }),
52
+ ...preserved.map(name => ({ name, color: 'GRAY', id: atual[name] })),
43
53
  ];
44
54
  return { missing, preserved, ordered };
45
55
  }
@@ -61,7 +71,9 @@ async function syncStages(token, snapshot, options) {
61
71
  return false;
62
72
  }
63
73
 
64
- const current = Object.keys(etapa.options || {});
74
+ // Mapa nome id: é o id que preserva o vínculo dos itens com a opção.
75
+ const current = etapa.options || {};
76
+ const nomesAntes = Object.keys(current);
65
77
  const { missing, preserved, ordered } = planStageSync(current, STATUS_OPTIONS);
66
78
 
67
79
  if (missing.length === 0) {
@@ -74,7 +86,7 @@ async function syncStages(token, snapshot, options) {
74
86
  (preserved.length
75
87
  ? `${chalk.dim('=')} preservar (fora do fluxo canônico): ${preserved.join(', ')}\n`
76
88
  : '') +
77
- `${chalk.dim('=')} preservar (canônicas já presentes): ${current.length - preserved.length}\n\n` +
89
+ `${chalk.dim('=')} preservar (canônicas já presentes): ${nomesAntes.length - preserved.length}\n\n` +
78
90
  chalk.dim(`Resultado: ${ordered.length} opções. Nenhuma é removida.`),
79
91
  'Plano para o campo "Etapa"'
80
92
  );
@@ -115,8 +127,13 @@ async function syncStages(token, snapshot, options) {
115
127
  p.log.warn(`Campo atualizado, mas a verificação falhou: ${err.message}`);
116
128
  return true;
117
129
  }
118
- const now = Object.keys(after?.fields?.['Etapa']?.options || {});
119
- const lost = current.filter(name => !now.includes(name));
130
+ const depois = after?.fields?.['Etapa']?.options || {};
131
+ const now = Object.keys(depois);
132
+ const lost = nomesAntes.filter(name => !now.includes(name));
133
+ // A verificação que importa: o ID de cada opção preexistente tem que ser o
134
+ // MESMO. Conferir só o nome era o que deixava passar o pior desfecho — as 12
135
+ // opções presentes, todos os ids trocados, e o board inteiro sem Etapa.
136
+ const recriadas = nomesAntes.filter(name => depois[name] && depois[name] !== current[name]);
120
137
  const stillMissing = STATUS_OPTIONS.map(o => o.name).filter(name => !now.includes(name));
121
138
  spinner.stop(`Campo "Etapa" com ${now.length} opções.`);
122
139
 
@@ -128,6 +145,15 @@ async function syncStages(token, snapshot, options) {
128
145
  process.exitCode = 1;
129
146
  return false;
130
147
  }
148
+ if (recriadas.length > 0) {
149
+ p.log.error(
150
+ `Opções RECRIADAS com id novo: ${recriadas.join(', ')}. Os itens que estavam nelas ` +
151
+ 'perderam a Etapa — reposicione-os no board. Isto é um bug do comando, não do seu ' +
152
+ 'Project: reporte com a saída acima.'
153
+ );
154
+ process.exitCode = 1;
155
+ return false;
156
+ }
131
157
  if (stillMissing.length > 0) {
132
158
  p.log.warn(`Etapas canônicas ainda ausentes: ${stillMissing.join(', ')}.`);
133
159
  return true;
@@ -0,0 +1,145 @@
1
+ // Modo de execução do fluxo: GitHub Actions ou sessão local.
2
+ //
3
+ // `generate-spec`, `generate-plan` e `decompose` nasceram como comandos de
4
+ // Action e exigiam `GITHUB_REPOSITORY`, recusando qualquer execução fora do
5
+ // runner. Mas eles são só comandos — o que os prendia ao CI era a resolução de
6
+ // owner/repo, não o trabalho em si. Agora o MESMO comando roda nos dois lugares:
7
+ // dentro do Action, disparado por label, ou na sua sessão do agente.
8
+ //
9
+ // O modo é detectado pelo ambiente (`GITHUB_ACTIONS`), não por flag: um
10
+ // comando com dois nomes para a mesma coisa envelhece mal.
11
+ //
12
+ // **O comportamento é deliberadamente IDÊNTICO nos dois modos** — gera, commita,
13
+ // dá pull --rebase, faz push, comenta na issue e avança a Etapa. O board é a
14
+ // fonte de verdade do RFC-001 independentemente de onde a geração rodou; um modo
15
+ // local que não sincronizasse o board deixaria o próximo passo do fluxo cego.
16
+ //
17
+ // Duas diferenças existem, e as duas são de SEGURANÇA, não de resultado:
18
+ //
19
+ // 1. IDENTIDADE DO GIT. O Action roda `git config user.email "spec-wave[bot]"`
20
+ // sem `--global`, o que grava em `.git/config`. Num runner descartável isso
21
+ // é inócuo; no seu clone, mudaria o autor de TODOS os seus commits futuros
22
+ // naquele repositório. Localmente a sua identidade é preservada.
23
+ // 2. FALHA DE PUSH. No Action, não conseguir publicar é falha do job. Local, o
24
+ // arquivo já está gerado e commitado — perder isso porque o remoto andou
25
+ // seria pior que avisar e deixar você resolver o push.
26
+
27
+ import { execSync } from 'node:child_process';
28
+ import { mkdirSync, writeFileSync } from 'node:fs';
29
+ import path from 'node:path';
30
+ import { resolveRepoContext } from './project-root.mjs';
31
+ import { CONFIG_FILE } from '../config.mjs';
32
+
33
+ /** Rodando dentro do GitHub Actions? (função PURA) */
34
+ export function isActionsRun(env = process.env) {
35
+ return env.GITHUB_ACTIONS === 'true';
36
+ }
37
+
38
+ /** 'actions' | 'local' (função PURA) */
39
+ export function executionMode(env = process.env) {
40
+ return isActionsRun(env) ? 'actions' : 'local';
41
+ }
42
+
43
+ /**
44
+ * Resolve owner/repo + modo, ou lança com a instrução certa para cada contexto.
45
+ *
46
+ * Nos Actions o `GITHUB_REPOSITORY` vem do runner; local, o `.spec-wave.json`
47
+ * gravado pelo `init` é a fonte. `resolveRepoContext` já cobre os dois.
48
+ *
49
+ * @param {object} [opts]
50
+ * @param {string} [opts.cwd]
51
+ * @param {string} [opts.command] nome do comando, para a mensagem de erro
52
+ * @param {object} [opts.env]
53
+ * @returns {{owner: string, repo: string, root: string|null, config: object|null, mode: 'actions'|'local'}}
54
+ */
55
+ export function resolveFlowContext({ cwd = process.cwd(), command = 'este comando', env = process.env } = {}) {
56
+ // Repassa o `env` recebido: sem isso o modo local fica intestável dentro do
57
+ // GitHub Actions, que define GITHUB_REPOSITORY em toda execução.
58
+ const { owner, repo, root, config } = resolveRepoContext(cwd, env);
59
+ const mode = executionMode(env);
60
+
61
+ if (!owner || !repo) {
62
+ throw new Error(
63
+ 'Não foi possível determinar owner/repo.\n' +
64
+ (mode === 'actions'
65
+ ? 'No GitHub Actions, o runner define GITHUB_REPOSITORY — verifique o workflow.'
66
+ : `Rode dentro de um repositório com ${CONFIG_FILE} (\`spec-wave init\`), ` +
67
+ `ou defina GITHUB_REPOSITORY=owner/repo:\n` +
68
+ ` GITHUB_REPOSITORY=owner/repo spec-wave ${command} --issue-number 1`)
69
+ );
70
+ }
71
+ return { owner, repo, root, config, mode };
72
+ }
73
+
74
+ /**
75
+ * Grava um arquivo gerado e o publica: commit + pull --rebase + push.
76
+ *
77
+ * Era o mesmo bloco copiado em `generate-spec`, `generate-plan` e `decompose`,
78
+ * com a identidade do bot embutida. Centralizado aqui para que a diferença
79
+ * entre os modos exista num lugar só.
80
+ *
81
+ * O commit é escopado ao caminho (`git commit -- <arquivo>`): sem isso, qualquer
82
+ * coisa que você já tivesse no index entraria junto no commit do spec-wave —
83
+ * irrelevante num runner limpo, nada irrelevante no seu clone.
84
+ *
85
+ * @param {object} params
86
+ * @param {string} params.filePath caminho absoluto do arquivo
87
+ * @param {string} params.content
88
+ * @param {string} params.message mensagem de commit
89
+ * @param {'actions'|'local'} params.mode
90
+ * @returns {{committed: boolean, pushed: boolean, warning: string|null}}
91
+ */
92
+ export function commitGenerated({ filePath, content, message, mode }) {
93
+ mkdirSync(path.dirname(filePath), { recursive: true });
94
+ writeFileSync(filePath, content, 'utf-8');
95
+
96
+ const git = (cmd, opts = {}) => execSync(cmd, { stdio: 'inherit', ...opts });
97
+ const gitQuiet = (cmd) => execSync(cmd, { stdio: 'pipe' }).toString().trim();
98
+
99
+ if (mode === 'actions') {
100
+ // Runner descartável: identidade do bot é o que se quer no histórico.
101
+ git('git config user.email "spec-wave[bot]@github.com"');
102
+ git('git config user.name "spec-wave[bot]"');
103
+ }
104
+
105
+ git(`git add "${filePath}"`);
106
+
107
+ // Nada mudou (regerar conteúdo idêntico) → `git commit` sairia 1 e derrubaria
108
+ // o comando depois de o trabalho estar feito.
109
+ let hasChanges = true;
110
+ try {
111
+ execSync(`git diff --cached --quiet -- "${filePath}"`, { stdio: 'pipe' });
112
+ hasChanges = false;
113
+ } catch {
114
+ hasChanges = true;
115
+ }
116
+ if (!hasChanges) {
117
+ return { committed: false, pushed: false, warning: 'conteúdo idêntico ao já versionado — nada a commitar' };
118
+ }
119
+
120
+ git(`git commit -m "${message}" -- "${filePath}"`);
121
+
122
+ try {
123
+ git('git pull --rebase');
124
+ git('git push');
125
+ return { committed: true, pushed: true, warning: null };
126
+ } catch (err) {
127
+ if (mode === 'actions') throw err;
128
+ // Local: o arquivo está gerado e commitado. Derrubar o comando aqui
129
+ // esconderia esse fato atrás de um erro de rede/divergência.
130
+ const branch = (() => {
131
+ try {
132
+ return gitQuiet('git rev-parse --abbrev-ref HEAD');
133
+ } catch {
134
+ return 'seu branch';
135
+ }
136
+ })();
137
+ return {
138
+ committed: true,
139
+ pushed: false,
140
+ warning:
141
+ `commit feito em ${branch}, mas o push falhou (${err.message.split('\n')[0]}). ` +
142
+ 'O arquivo está salvo e versionado — publique quando resolver.',
143
+ };
144
+ }
145
+ }
@@ -76,12 +76,19 @@ export function resolveFromRoot(root, ...parts) {
76
76
  *
77
77
  * Era o mesmo bloco copiado em story/task/order/qa/code-review/validate.
78
78
  *
79
+ * `env` é INJETÁVEL, e precisa ser: ler `process.env` aqui dentro tornava
80
+ * impossível testar o caminho local. Quem chamava com `env: {}` para simular
81
+ * "sem GITHUB_REPOSITORY" recebia o valor real assim mesmo — o teste passava na
82
+ * máquina do dev (onde a env não existe) e falhava no GitHub Actions (onde o
83
+ * runner a define).
84
+ *
79
85
  * @param {string} [cwd=process.cwd()]
86
+ * @param {object} [env=process.env] ambiente; injetável para teste
80
87
  * @returns {{ owner: string|undefined, repo: string|undefined,
81
88
  * root: string|null, config: object|null, error: string|null }}
82
89
  */
83
- export function resolveRepoContext(cwd = process.cwd()) {
84
- const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
90
+ export function resolveRepoContext(cwd = process.cwd(), env = process.env) {
91
+ const [envOwner, envRepo] = (env.GITHUB_REPOSITORY || '').split('/');
85
92
  const { config, root, error } = loadConfig(cwd);
86
93
  return {
87
94
  owner: envOwner || config?.owner,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "spec-wave",
3
3
  "displayName": "Spec Wave",
4
- "version": "0.16.0",
4
+ "version": "0.16.1",
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",
@@ -38,8 +38,11 @@ spec-wave:decompose-apply
38
38
 
39
39
  1. **Pré-requisito.** Feature: confirme que está em **✅ Ready** (spec e plan validados — skill **ready**). RFC: basta a descrição estar completa.
40
40
 
41
- 2. **Etapa 1 — gerar o rascunho:**
41
+ 2. **Etapa 1 — gerar o rascunho**, no modo que preferir (mesmo resultado; o modo é detectado pelo ambiente):
42
42
  ```bash
43
+ # local — resultado nesta sessão
44
+ npx @spec-wave/cli@latest decompose --issue-number <número>
45
+ # ou Action — assíncrono
43
46
  gh issue edit <número> --add-label "spec-wave:decompose"
44
47
  ```
45
48
  Informe: "Rascunho iniciado — vai commitar o `decomposition.md` e passar pela crítica. **Nenhuma issue é criada nesta etapa.**"
@@ -54,6 +57,9 @@ spec-wave:decompose-apply
54
57
 
55
58
  4. **Etapa 2 — aplicar o rascunho aprovado**, só depois da revisão:
56
59
  ```bash
60
+ # local
61
+ npx @spec-wave/cli@latest decompose --issue-number <número> --apply
62
+ # ou Action
57
63
  gh issue edit <número> --add-label "spec-wave:decompose-apply"
58
64
  ```
59
65
  Aplicar essa label **é** a aprovação humana — não há nova crítica.
@@ -12,7 +12,9 @@ allowed-tools:
12
12
 
13
13
  # spec-wave plan — plano técnico (2º documento)
14
14
 
15
- > **Regra fundamental: nunca gere o `plan.md` você mesmo.** Aplique a label e deixe o Action gerar e commitar. Exceção: revisar/melhorar um plano já gerado.
15
+ > **Regra fundamental: nunca escreva o `plan.md` à mão.** Quem gera é o spec-wave pelo Action ou pela CLI local. Exceção: revisar/melhorar um plano já gerado.
16
+
17
+ **Dois modos, mesmo resultado.** `npx @spec-wave/cli@latest generate-plan --issue-number <n>` roda **agora**, nesta sessão; a label `spec-wave:plan` roda no Action. O modo é detectado pelo ambiente. Ambos geram, commitam, fazem push, criticam e comentam na issue. Local exige a chave de IA no seu ambiente. Veja a skill **spec** para a tabela completa.
16
18
 
17
19
  **Contexto:** leia `.spec-wave.json` (Read). Ausente → skill **setup**.
18
20
 
@@ -24,8 +26,11 @@ O plano segue o schema do **RFC-002 §3.2**: **Estratégia Técnica** (com Matri
24
26
 
25
27
  2. **Garanta o `tech_context`.** Verifique se `.github/config/tech_context.yml` existe (Read). **Se não existir, ajude a criar AGORA** — o passo a passo está em `reference/tech-context.md`, ao lado deste arquivo. Garanta que esteja **commitado e pushado** antes de aplicar a label: o Action lê o arquivo do repositório, não do seu disco local.
26
28
 
27
- 3. Adicione a label de gatilho:
29
+ 3. **Acione**, no modo escolhido:
28
30
  ```bash
31
+ # local — resultado nesta sessão
32
+ npx @spec-wave/cli@latest generate-plan --issue-number <número>
33
+ # ou Action — assíncrono
29
34
  gh issue edit <número> --add-label "spec-wave:plan"
30
35
  ```
31
36
 
@@ -9,7 +9,18 @@ allowed-tools:
9
9
 
10
10
  # spec-wave spec — especificação funcional (1º documento)
11
11
 
12
- > **Regra fundamental: nunca gere o `spec.md` você mesmo.** Aplique a label e deixe o Action gerar é isso que garante que o arquivo seja commitado no repositório e referenciado na issue. Exceção: revisar/melhorar um `spec.md` já gerado (aí sim use Edit no arquivo local).
12
+ > **Regra fundamental: nunca escreva o `spec.md` à mão.** Quem gera é o spec-wave pelo Action ou pela CLI local. É isso que garante que o arquivo seja commitado e referenciado na issue. Exceção: revisar/melhorar um `spec.md` já gerado (aí sim use Edit).
13
+
14
+ ## Dois modos, mesmo resultado
15
+
16
+ | Modo | Como acionar | Quando |
17
+ |------|--------------|--------|
18
+ | **Action** | aplicar a label `spec-wave:spec` | fluxo assíncrono; roda no CI, você acompanha pela issue |
19
+ | **Local** | `npx @spec-wave/cli@latest generate-spec --issue-number <n>` | você quer o documento **agora**, nesta sessão, e iterar em cima dele |
20
+
21
+ O modo é detectado pelo ambiente — fora do Actions, a CLI roda em modo local automaticamente. Os dois geram o arquivo, commitam, dão push, comentam na issue e removem a label de gatilho. **Pergunte ao usuário qual ele quer** se não estiver claro; na dúvida numa sessão interativa, prefira o local (o resultado aparece em segundos, em vez de exigir acompanhar o Action).
22
+
23
+ > Local exige a chave de IA no seu ambiente (`OPENROUTER_API_KEY` ou `ANTHROPIC_API_KEY`) e um `.spec-wave.json` no repositório. O provider `anthropic` **só** funciona local — no Action ele precisaria do Claude Code como subprocesso, que o runner não tem.
13
24
 
14
25
  **Contexto:** leia `.spec-wave.json` (Read). Ausente → skill **setup**.
15
26
 
@@ -19,12 +30,19 @@ allowed-tools:
19
30
 
20
31
  > **Apenas Features.** Para **Spike, RFC e Bug** o Action **pula** a geração, remove a label e comenta. Não use esta skill nesses tipos.
21
32
 
22
- 2. Adicione a label de gatilho:
33
+ 2. **Escolha o modo** (veja a tabela acima) e acione:
34
+
35
+ **Local** — resultado nesta sessão:
23
36
  ```bash
24
- gh issue edit <número> --add-label "spec-wave:spec"
37
+ npx @spec-wave/cli@latest generate-spec --issue-number <número>
25
38
  ```
39
+ O comando imprime `Modo de execução: local`, gera, commita e faz push.
26
40
 
27
- 3. Informe ao usuário: "Label `spec-wave:spec` adicionada. O Action `generate-spec.yml` vai gerar o `spec.md` automaticamente. Acompanhe em Actions → Generate Spec."
41
+ **Action** assíncrono:
42
+ ```bash
43
+ gh issue edit <número> --add-label "spec-wave:spec"
44
+ ```
45
+ Informe: "Label `spec-wave:spec` adicionada. O Action `generate-spec.yml` vai gerar o `spec.md`. Acompanhe em Actions → Generate Spec."
28
46
 
29
47
  4. Quando concluir, ofereça revisar o arquivo em `docs/features/<slug>/spec.md`. O slug vem do título: `[FEATURE] Cadastro de Pedidos com PIX` → `cadastro-de-pedidos-com-pix`.
30
48
 
@@ -27,7 +27,11 @@ Toda a CLI é invocada como `npx @spec-wave/cli@latest <comando>`.
27
27
 
28
28
  ## Regras fundamentais
29
29
 
30
- 1. **Nunca gere `spec.md` ou `plan.md` você mesmo.** Aplique a label de gatilho e deixe o Action gerar e commitar. Exceção: revisar/melhorar um documento já gerado.
30
+ 1. **Nunca escreva `spec.md` ou `plan.md` à mão.** Quem gera é o spec-wave e ele roda de **dois modos**, com o mesmo resultado:
31
+ - **Action:** aplique a label de gatilho (`spec-wave:spec`, `spec-wave:plan`, `spec-wave:decompose`);
32
+ - **Local:** `npx @spec-wave/cli@latest generate-spec|generate-plan|decompose --issue-number <n>` na sua sessão.
33
+
34
+ O modo é detectado pelo ambiente (`GITHUB_ACTIONS`), sem flag. Os dois geram, commitam, fazem push, comentam na issue e sincronizam o board — o board é a fonte de verdade independentemente de onde rodou. Local exige a chave de IA no seu ambiente e um `.spec-wave.json`. Exceção à regra: revisar/melhorar um documento já gerado.
31
35
  2. **Nunca crie Story ou Task avulsa.** Elas nascem do `decompose`, já em **✅ Ready** e vinculadas ao pai. Criadas à mão caem em 📥 Backlog e **não aparecem em tela nenhuma** da UI (o inbox do PM lista Features, a tela do Dev lê 🚧 Desenvolvimento, a fila do TL lê ✅ Ready).
32
36
  3. **Nunca use `gh issue create`** para work items — não adiciona ao Project, a issue fica sem Etapa e some das telas. Use `npx @spec-wave/cli@latest issue`.
33
37
  4. **A Etapa só avança, nunca retrocede.** O campo **Status** (Todo / In Progress / Done) mede o progresso *dentro* da Etapa e reinicia a cada avanço. Prefira `move`, `task start|done` e `story review` a mutações GraphQL manuais — os comandos embutem essas regras.