@spec-wave/cli 0.11.0 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -85,6 +85,27 @@ Ferramenta Node.js que configura e opera o fluxo via linha de comando.
85
85
  | `code-review.yml` | PR aberto/reaberto | Move Feature para `👀 Code Review` |
86
86
  | `qa.yml` | PR aprovado | Move Feature para `🧪 QA` |
87
87
 
88
+ **Re-executar uma etapa (`spec-wave:force`).** Adicionada *junto* de uma label de gatilho, a label `spec-wave:force` manda o comando re-executar a etapa ignorando os guards; ela é consumida pelo run (vale uma vez). Sozinha não dispara nada.
89
+
90
+ ```bash
91
+ gh issue edit 12 --add-label "spec-wave:force" --add-label "spec-wave:decompose"
92
+ ```
93
+
94
+ No `decompose` ela **fecha as sub-issues da decomposição anterior** (Stories e suas Tasks) antes de gerar as novas — operação destrutiva, com aviso no comentário da issue quando alguma já saiu de `✅ Ready`. Em `generate-spec`/`generate-plan` ela **versiona** o documento em vez de sobrescrever (veja abaixo). A flag equivalente para execução local é `--force`.
95
+
96
+ ### Documentos versionados
97
+
98
+ A primeira geração escreve `spec.md`/`plan.md` (v1). Cada regeração **forçada** grava a próxima versão ao lado, preservando as anteriores:
99
+
100
+ ```
101
+ docs/features/<slug>/
102
+ spec.md ← v1
103
+ spec-v2.md ← regeração forçada ⬅ spec atual
104
+ plan.md ⬅ plano atual (spec e plan versionam independente)
105
+ ```
106
+
107
+ A **maior versão é o documento atual**: é ela que o `generate-plan` usa como contexto, que o `validate` valida, e que o `decompose` e o `implement` leem. Uma regeração **sem** force sobrescreve essa versão atual.
108
+
88
109
  ### Skill (`src/templates/skill/SKILL.md`)
89
110
 
90
111
  Skill que guia o usuário pelo fluxo via comandos como `/spec-wave spec 42`, `/spec-wave plan 42`, `/spec-wave decompose 42`. A skill lê o `.spec-wave.json` local, detecta o estado atual e executa os comandos corretos sem abrir wizards interativos. Instale-a no seu agente com `install-skill` (ver abaixo).
package/bin/spec-wave.mjs CHANGED
@@ -132,6 +132,7 @@ program
132
132
  .command('generate-plan')
133
133
  .description('Gera plan.md para uma Feature (usado pelo GitHub Action)')
134
134
  .requiredOption('--issue-number <n>', 'Número da issue no GitHub')
135
+ .option('--force', 'Re-executa a etapa ignorando os guards (equivale à label spec-wave:force)')
135
136
  .action(async (options) => {
136
137
  const { generatePlan } = await import('../src/commands/generate-plan.mjs');
137
138
  await generatePlan(options).catch(err => { console.error(err.message); process.exit(1); });
@@ -141,6 +142,7 @@ program
141
142
  .command('generate-spec')
142
143
  .description('Gera spec.md para uma Feature (usado pelo GitHub Action)')
143
144
  .requiredOption('--issue-number <n>', 'Número da issue no GitHub')
145
+ .option('--force', 'Re-executa a etapa ignorando os guards (equivale à label spec-wave:force)')
144
146
  .action(async (options) => {
145
147
  const { generateSpec } = await import('../src/commands/generate-spec.mjs');
146
148
  await generateSpec(options).catch(err => { console.error(err.message); process.exit(1); });
@@ -159,6 +161,7 @@ program
159
161
  .command('decompose')
160
162
  .description('Decompõe uma Feature em Stories e Tasks (usado pelo GitHub Action)')
161
163
  .requiredOption('--issue-number <n>', 'Número da issue no GitHub')
164
+ .option('--force', 'Re-executa a etapa ignorando os guards (equivale à label spec-wave:force)')
162
165
  .action(async (options) => {
163
166
  const { decompose } = await import('../src/commands/decompose.mjs');
164
167
  await decompose(options).catch(err => { console.error(err.message); process.exit(1); });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spec-wave/cli",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
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": {
@@ -198,6 +198,7 @@ export async function listSubIssues(token, issueNodeId) {
198
198
  number
199
199
  title
200
200
  body
201
+ state
201
202
  labels(first: 20) { nodes { name } }
202
203
  }
203
204
  }
@@ -211,6 +212,9 @@ export async function listSubIssues(token, issueNodeId) {
211
212
  title: n.title,
212
213
  body: n.body || '',
213
214
  nodeId: n.id,
215
+ // 'OPEN' | 'CLOSED' (GraphQL) normalizado para o vocabulário do REST, que
216
+ // é o que o resto do código compara (issue.state === 'closed').
217
+ state: n.state === 'CLOSED' ? 'closed' : 'open',
214
218
  labels: (n.labels?.nodes || []).map(l => l.name),
215
219
  }));
216
220
  }
@@ -101,6 +101,19 @@ export async function getIssue(token, owner, repo, issueNumber) {
101
101
  return res.data;
102
102
  }
103
103
 
104
+ // Fecha uma issue. Usado pelo re-decompose forçado para limpar as sub-issues
105
+ // da decomposição anterior antes de gerar as novas.
106
+ export async function closeIssue(token, owner, repo, issueNumber, reason = 'not_planned') {
107
+ const octokit = makeOctokit(token);
108
+ await octokit.rest.issues.update({
109
+ owner,
110
+ repo,
111
+ issue_number: issueNumber,
112
+ state: 'closed',
113
+ state_reason: reason,
114
+ });
115
+ }
116
+
104
117
  export async function deleteLabel(token, owner, repo, name) {
105
118
  const octokit = makeOctokit(token);
106
119
  try {
@@ -1,8 +1,10 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { readFileSync } from 'node:fs';
2
2
  import { resolveToken } from '../api/auth.mjs';
3
- import { getIssue, createIssue, removeLabel, addLabel, commentOnIssue, addBlockedBy } from '../api/github-rest.mjs';
4
- import { addSubIssue, listSubIssues } from '../api/github-graphql.mjs';
3
+ import { getIssue, createIssue, removeLabel, addLabel, commentOnIssue, addBlockedBy, closeIssue } from '../api/github-rest.mjs';
4
+ import { addSubIssue, listSubIssues, addProjectItem, getItemSingleSelectValue } from '../api/github-graphql.mjs';
5
5
  import { loadProjectConfig, resolveField, advanceToStage } from '../lib/board.mjs';
6
+ import { isForced, consumeForceLabel } from '../lib/force.mjs';
7
+ import { resolveDoc } from '../lib/feature-docs.mjs';
6
8
  import { generateDocument } from '../lib/claude.mjs';
7
9
  import { runCritique } from '../lib/critique.mjs';
8
10
  import { recordUsage } from '../lib/usage-report.mjs';
@@ -10,7 +12,10 @@ import { formatDependencyLine } from '../lib/dependencies.mjs';
10
12
  import { lintLanguage } from '../lib/output-lint.mjs';
11
13
  import { slugify } from '../lib/slugify.mjs';
12
14
  import { detectIssueType } from '../lib/issue-type.mjs';
13
- import { DECOMPOSE_TARGETS, LABEL_DECOMPOSED, LABEL_CRITIQUE_FAILED, TARGET_LANGUAGE, STAGE_READY, PROGRESS_TODO } from '../config.mjs';
15
+ import {
16
+ DECOMPOSE_TARGETS, LABEL_DECOMPOSED, LABEL_CRITIQUE_FAILED, LABEL_FORCE,
17
+ TARGET_LANGUAGE, STAGE_READY, STAGE_DEVELOPMENT, STAGE_ORDER, PROGRESS_TODO,
18
+ } from '../config.mjs';
14
19
 
15
20
  // Adiciona a issue ao board na Etapa ✅ Ready / Status Todo. Best-effort; a
16
21
  // Etapa nunca retrocede (advanceToStage não toca itens já adiante).
@@ -19,15 +24,61 @@ async function moveToReady(token, project, etapaField, statusField, nodeId) {
19
24
  await advanceToStage(token, project, etapaField, statusField, nodeId, STAGE_READY, PROGRESS_TODO);
20
25
  }
21
26
 
22
- // Extrai JSON da resposta do modelo (tolera texto em volta).
23
- function parseJson(raw) {
24
- try {
25
- return JSON.parse(raw);
26
- } catch {
27
- const jsonMatch = raw.match(/\{[\s\S]*\}/);
28
- if (!jsonMatch) throw new Error('Claude did not return valid JSON');
29
- return JSON.parse(jsonMatch[0]);
27
+ // Dobra as barras invertidas que NÃO iniciam um escape válido de JSON. O corpo
28
+ // das tasks costuma trazer trecho de shell/YAML/regex ("\d+", "gradlew \" no fim
29
+ // da linha) e o modelo emite a barra crua: `JSON.parse` morre com "Bad escaped
30
+ // character". Escapes válidos são consumidos inteiros pela regex, então `\\`,
31
+ // `\n` e `\uXXXX` passam intactos.
32
+ function repairEscapes(text) {
33
+ return text.replace(/\\(u[0-9a-fA-F]{4}|["\\/bfnrt])?/g, (match, valid) => (valid ? match : '\\\\'));
34
+ }
35
+
36
+ // Trecho ao redor da posição que o V8 reporta ("... at position 18321"), para o
37
+ // log da Action mostrar O QUE quebrou em vez de só um offset.
38
+ function excerptAt(text, message) {
39
+ const at = /position (\d+)/.exec(message || '');
40
+ if (!at) return '';
41
+ const pos = Number(at[1]);
42
+ const excerpt = text.slice(Math.max(0, pos - 60), pos + 60).replace(/\s+/g, ' ').trim();
43
+ return excerpt ? ` Trecho: …${excerpt}…` : '';
44
+ }
45
+
46
+ /**
47
+ * Extrai o JSON da resposta do modelo (função PURA — testável).
48
+ *
49
+ * Tenta, em ordem: conteúdo de fence de código, resposta inteira e o primeiro
50
+ * objeto `{...}` do texto; cada candidato é parseado cru e, se falhar, com os
51
+ * escapes reparados. Sem candidato válido, lança com o motivo e o trecho.
52
+ *
53
+ * @param {string} raw resposta bruta do modelo
54
+ * @returns {object} objeto decodificado
55
+ */
56
+ export function parseModelJson(raw) {
57
+ const text = String(raw ?? '');
58
+ const candidates = [];
59
+ const fence = text.match(/```(?:json)?\s*\n?([\s\S]*?)```/);
60
+ if (fence) candidates.push(fence[1]);
61
+ candidates.push(text);
62
+ const obj = text.match(/\{[\s\S]*\}/);
63
+ if (obj) candidates.push(obj[0]);
64
+
65
+ let failure = null;
66
+ for (const candidate of candidates) {
67
+ const trimmed = candidate.trim();
68
+ if (!trimmed) continue;
69
+ for (const attempt of [trimmed, repairEscapes(trimmed)]) {
70
+ try {
71
+ return JSON.parse(attempt);
72
+ } catch (err) {
73
+ failure = { message: err.message, text: attempt };
74
+ }
75
+ }
30
76
  }
77
+
78
+ throw new Error(
79
+ `O modelo não devolveu JSON válido: ${failure?.message ?? 'nenhum objeto encontrado na resposta'}.` +
80
+ (failure ? excerptAt(failure.text, failure.message) : '')
81
+ );
31
82
  }
32
83
 
33
84
  // Prefixo de título das sub-issues geradas por cada tipo decompoível.
@@ -40,13 +91,19 @@ const CHILD_PREFIX = { Feature: '[STORY]', RFC: '[TASK]' };
40
91
  * sub-issues já contêm um item do tipo-alvo (Feature → algum `[STORY]` no
41
92
  * título; RFC → algum `[TASK]`). Sub-issues de outro tipo não contam.
42
93
  *
94
+ * Com `force` (flag `--force` ou label `spec-wave:force`) nada é pulado — o
95
+ * chamador é quem limpa a decomposição anterior antes de gerar a nova.
96
+ *
43
97
  * @param {object} params
44
98
  * @param {Array<string|{name: string}>} [params.labels] labels da issue
45
99
  * @param {Array<{ number?: number, title?: string }>} [params.subIssues] sub-issues existentes
46
100
  * @param {string} params.type tipo da issue ('Feature' | 'RFC')
101
+ * @param {boolean} [params.force] re-executa ignorando os guards
47
102
  * @returns {{ skip: boolean, reason: string }}
48
103
  */
49
- export function shouldSkipDecompose({ labels = [], subIssues = [], type } = {}) {
104
+ export function shouldSkipDecompose({ labels = [], subIssues = [], type, force = false } = {}) {
105
+ if (force) return { skip: false, reason: '' };
106
+
50
107
  const names = labels
51
108
  .map(l => (typeof l === 'string' ? l : l?.name))
52
109
  .filter(Boolean);
@@ -66,6 +123,103 @@ export function shouldSkipDecompose({ labels = [], subIssues = [], type } = {})
66
123
  return { skip: false, reason: '' };
67
124
  }
68
125
 
126
+ /**
127
+ * Planeja a limpeza de um re-decompose forçado (função PURA — testável).
128
+ *
129
+ * Só entram as sub-issues do tipo-alvo (Feature → `[STORY]`, RFC → `[TASK]`)
130
+ * que ainda estão abertas — as já fechadas não precisam de nada. Separa as que
131
+ * já saíram de ✅ Ready (Etapa ≥ 🚧 Desenvolvimento): fechá-las descarta
132
+ * trabalho em andamento, então elas são destacadas no aviso.
133
+ *
134
+ * @param {object} params
135
+ * @param {Array<{number:number, title?:string, nodeId?:string, state?:string}>} [params.subIssues]
136
+ * @param {string} params.type tipo da issue pai ('Feature' | 'RFC')
137
+ * @param {Record<number, string|null>} [params.stages] Etapa atual por número de issue
138
+ * @returns {{ close: object[], started: object[] }} close: a fechar (com `stage`);
139
+ * started: subconjunto de close que já passou de Ready.
140
+ */
141
+ export function planForcedCleanup({ subIssues = [], type, stages = {} } = {}) {
142
+ const prefix = CHILD_PREFIX[type];
143
+ if (!prefix) return { close: [], started: [] };
144
+
145
+ const devIdx = STAGE_ORDER.indexOf(STAGE_DEVELOPMENT);
146
+ const close = subIssues
147
+ .filter(s => (s.title || '').includes(prefix) && s.state !== 'closed')
148
+ .map(s => ({ ...s, stage: stages[s.number] ?? null }));
149
+ const started = close.filter(s => {
150
+ const idx = s.stage ? STAGE_ORDER.indexOf(s.stage) : -1;
151
+ return idx !== -1 && devIdx !== -1 && idx >= devIdx;
152
+ });
153
+ return { close, started };
154
+ }
155
+
156
+ // Fecha a decomposição anterior (sub-issues do tipo-alvo e os filhos delas) e
157
+ // comenta o que foi fechado. Best-effort item a item: uma falha vira warn e o
158
+ // re-decompose segue — melhor uma issue órfã do que abortar no meio.
159
+ async function closeStaleSubIssues(ctx, subIssues) {
160
+ const { token, projectToken, owner, repo, issueNumber, project, etapaField, type } = ctx;
161
+
162
+ // Etapa de cada sub-issue (best-effort) — só para avisar o que já saiu de Ready.
163
+ const stages = {};
164
+ if (project?.id && etapaField?.id) {
165
+ await Promise.all(subIssues.map(async (s) => {
166
+ if (!s.nodeId) return;
167
+ try {
168
+ const itemId = await addProjectItem(projectToken, project.id, s.nodeId);
169
+ stages[s.number] = await getItemSingleSelectValue(projectToken, itemId, etapaField.id);
170
+ } catch {
171
+ stages[s.number] = null;
172
+ }
173
+ }));
174
+ }
175
+
176
+ const { close, started } = planForcedCleanup({ subIssues, type, stages });
177
+ if (close.length === 0) {
178
+ console.log('Re-decompose forçado: nenhuma sub-issue anterior aberta para fechar.');
179
+ return;
180
+ }
181
+
182
+ console.log(`Re-decompose forçado: fechando ${close.length} sub-issue(s) anterior(es)...`);
183
+ const closed = [];
184
+ for (const s of close) {
185
+ // Filhos primeiro (Tasks de uma Story): fechar só a Story deixaria as Tasks
186
+ // órfãs e abertas no board.
187
+ const children = s.nodeId ? await listSubIssues(token, s.nodeId).catch(() => []) : [];
188
+ for (const child of children.filter(c => c.state !== 'closed')) {
189
+ try {
190
+ await closeIssue(token, owner, repo, child.number);
191
+ } catch (err) {
192
+ console.warn(` Falha ao fechar #${child.number}: ${err.message}`);
193
+ }
194
+ }
195
+ try {
196
+ await closeIssue(token, owner, repo, s.number);
197
+ closed.push({ ...s, children: children.length });
198
+ console.log(` #${s.number} fechada${children.length ? ` (+${children.length} filha(s))` : ''}.`);
199
+ } catch (err) {
200
+ console.warn(` Falha ao fechar #${s.number}: ${err.message}`);
201
+ }
202
+ }
203
+
204
+ if (closed.length === 0) return;
205
+ const lines = closed.map(s =>
206
+ `- #${s.number} ${s.title}${s.stage ? ` — Etapa ${s.stage}` : ''}` +
207
+ (s.children ? ` _(+${s.children} sub-issue(s))_` : '')
208
+ );
209
+ const startedWarning = started.length > 0
210
+ ? `\n\n⚠️ ${started.length} delas já tinha(m) saído de **${STAGE_READY}**: ` +
211
+ `${started.map(s => `#${s.number} (${s.stage})`).join(', ')} — ` +
212
+ 'confira se algum trabalho em andamento foi descartado.'
213
+ : '';
214
+ await commentOnIssue(
215
+ token, owner, repo, parseInt(issueNumber, 10),
216
+ `🔀 **Re-decompose forçado** (label \`${LABEL_FORCE}\`)\n\n` +
217
+ `Fechadas ${closed.length} sub-issue(s) da decomposição anterior:\n\n${lines.join('\n')}` +
218
+ startedWarning +
219
+ '\n\nGerando a nova decomposição…'
220
+ ).catch(() => {});
221
+ }
222
+
69
223
  // Lint de idioma sobre títulos+corpos gerados; retorna aviso pronto para
70
224
  // anexar ao comentário final ('' se limpo).
71
225
  function formatItemsLintWarning(texts) {
@@ -132,14 +286,18 @@ async function decomposeFeature(ctx) {
132
286
  const slug = slugify(issue.title);
133
287
  const featureDir = `docs/features/${slug}`;
134
288
 
135
- const planContent = existsSync(`${featureDir}/plan.md`)
136
- ? readFileSync(`${featureDir}/plan.md`, 'utf-8')
137
- : '(plan.md não encontrado)';
138
- const specContent = existsSync(`${featureDir}/spec.md`)
139
- ? readFileSync(`${featureDir}/spec.md`, 'utf-8')
140
- : '(spec.md não encontrado)';
289
+ // Documentos ATUAIS (maior versão): com regerações forçadas o vigente é
290
+ // spec-v2.md/plan-v3.md…, não o arquivo original.
291
+ const spec = resolveDoc(featureDir, 'spec');
292
+ const plan = resolveDoc(featureDir, 'plan');
293
+ const specContent = spec ? readFileSync(spec.path, 'utf-8') : '(spec.md não encontrado)';
294
+ const planContent = plan ? readFileSync(plan.path, 'utf-8') : '(plan.md não encontrado)';
141
295
 
142
296
  console.log(`Decompondo Feature: ${issue.title}`);
297
+ if (spec || plan) {
298
+ console.log(`Documentos: ${spec ? `${spec.path} (v${spec.version})` : 'spec ausente'} · ` +
299
+ `${plan ? `${plan.path} (v${plan.version})` : 'plano ausente'}`);
300
+ }
143
301
  const userContent = [
144
302
  `Feature: ${issue.title}`,
145
303
  `Issue #${issueNumber}`,
@@ -147,7 +305,7 @@ async function decomposeFeature(ctx) {
147
305
  `\n## plan.md\n${planContent}`,
148
306
  ].join('\n');
149
307
 
150
- const decomposition = parseJson(await generateDocument(FEATURE_SYSTEM_PROMPT, userContent, { action: 'decompose', usage }));
308
+ const decomposition = parseModelJson(await generateDocument(FEATURE_SYSTEM_PROMPT, userContent, { action: 'decompose', usage }));
151
309
 
152
310
  // Crítica adversarial ANTES de criar qualquer issue: stories que contradizem
153
311
  // a spec/plan não devem virar trabalho. Crítica indisponível → só avisa.
@@ -155,8 +313,8 @@ async function decomposeFeature(ctx) {
155
313
  try {
156
314
  critique = await runCritique({
157
315
  kind: 'stories',
158
- spec: existsSync(`${featureDir}/spec.md`) ? specContent : null,
159
- plan: existsSync(`${featureDir}/plan.md`) ? planContent : null,
316
+ spec: spec ? specContent : null,
317
+ plan: plan ? planContent : null,
160
318
  stories: decomposition.stories,
161
319
  usage,
162
320
  });
@@ -283,7 +441,7 @@ async function decomposeRFC(ctx) {
283
441
  `\n## Descrição\n${issue.body || '(sem descrição)'}`,
284
442
  ].join('\n');
285
443
 
286
- const decomposition = parseJson(await generateDocument(RFC_SYSTEM_PROMPT, userContent, { action: 'decompose', usage }));
444
+ const decomposition = parseModelJson(await generateDocument(RFC_SYSTEM_PROMPT, userContent, { action: 'decompose', usage }));
287
445
  const rfcNodeId = issue.node_id;
288
446
  const tasks = decomposition.tasks || [];
289
447
  const created = [];
@@ -327,7 +485,7 @@ async function decomposeRFC(ctx) {
327
485
  console.log(`Decomposição concluída: ${created.length} tasks criadas.`);
328
486
  }
329
487
 
330
- export async function decompose({ issueNumber }) {
488
+ export async function decompose({ issueNumber, force = false }) {
331
489
  const token = await resolveToken();
332
490
  // PROJECT_TOKEN deve ter scope "project" para atualizar GitHub Projects v2.
333
491
  // Fallback para GITHUB_TOKEN (só funciona em repos pessoais sem org restrictions).
@@ -366,17 +524,25 @@ export async function decompose({ issueNumber }) {
366
524
  console.warn(`Não foi possível listar sub-issues: ${err.message} — seguindo sem o guard de sub-issues.`);
367
525
  subIssues = [];
368
526
  }
369
- const guard = shouldSkipDecompose({ labels: issue.labels || [], subIssues, type });
527
+ // Force (flag --force ou label spec-wave:force): ignora o guard e limpa a
528
+ // decomposição anterior antes de gerar a nova.
529
+ const forced = isForced({ labels: issue.labels || [], flag: force });
530
+ const guard = shouldSkipDecompose({ labels: issue.labels || [], subIssues, type, force: forced });
370
531
  if (guard.skip) {
371
532
  console.log(`Decompose ignorado: ${guard.reason}.`);
372
533
  await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
373
534
  await commentOnIssue(
374
535
  token, owner, repo, parseInt(issueNumber, 10),
375
- `⏭️ **decompose ignorado:** ${guard.reason}. Para forçar, remova a label ` +
376
- `\`${LABEL_DECOMPOSED}\` (e apague as sub-issues antigas se quiser re-gerar).`
536
+ `⏭️ **decompose ignorado:** ${guard.reason}.\n\n` +
537
+ `Para re-decompor, adicione a label \`${LABEL_FORCE}\` junto com \`spec-wave:decompose\` ` +
538
+ 'as sub-issues da decomposição anterior serão **fechadas** e novas serão geradas.'
377
539
  ).catch(() => {});
378
540
  return;
379
541
  }
542
+ // Consome a label assim que ela é lida — se o run for cancelado/morto mais
543
+ // adiante, ela não fica pendurada forçando silenciosamente os runs seguintes.
544
+ await consumeForceLabel(token, owner, repo, parseInt(issueNumber, 10));
545
+ if (forced) console.log(`Modo forçado ativo (${force ? 'flag --force' : `label ${LABEL_FORCE}`}).`);
380
546
 
381
547
  // Projeto + campos Etapa/Status do board (reutilizados em todos os itens).
382
548
  const { project, error: projectError } = loadProjectConfig();
@@ -399,8 +565,11 @@ export async function decompose({ issueNumber }) {
399
565
  // Coletor de uso de IA — o finally registra o custo já incorrido mesmo nos
400
566
  // fluxos que retornam cedo (ex.: abort da crítica grave) ou que falham.
401
567
  const usageEntries = [];
402
- const ctx = { token, projectToken, owner, repo, issue, issueNumber, project, etapaField, statusField, usage: usageEntries };
568
+ const ctx = { token, projectToken, owner, repo, issue, issueNumber, type, project, etapaField, statusField, usage: usageEntries };
403
569
  try {
570
+ // Limpeza do re-decompose forçado ANTES de gerar: fecha as sub-issues da
571
+ // decomposição anterior para o board não ficar com dois conjuntos.
572
+ if (forced) await closeStaleSubIssues(ctx, subIssues);
404
573
  if (type === 'Feature') await decomposeFeature(ctx);
405
574
  else if (type === 'RFC') await decomposeRFC(ctx);
406
575
  } finally {
@@ -1,5 +1,5 @@
1
1
  import { execSync } from 'node:child_process';
2
- import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
2
+ import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
3
3
  import { resolveToken } from '../api/auth.mjs';
4
4
  import { getIssue, removeLabel, addLabel, commentOnIssue } from '../api/github-rest.mjs';
5
5
  import { detectIssueType } from '../lib/issue-type.mjs';
@@ -8,6 +8,8 @@ import { generateDocument } from '../lib/claude.mjs';
8
8
  import { runCritique } from '../lib/critique.mjs';
9
9
  import { recordUsage } from '../lib/usage-report.mjs';
10
10
  import { slugify } from '../lib/slugify.mjs';
11
+ import { isForced, consumeForceLabel } from '../lib/force.mjs';
12
+ import { resolveDoc, resolveWritePath } from '../lib/feature-docs.mjs';
11
13
  import { buildTechContext } from '../lib/tech-context.mjs';
12
14
 
13
15
  // Aviso anexado ao comentário quando o lint de idioma ainda reprova após o
@@ -41,7 +43,7 @@ Regras OBRIGATÓRIAS:
41
43
  - Forneça detalhes acionáveis: caminhos exatos de endpoints, nomes de DTOs, constraints de banco.
42
44
  - Responda APENAS com o conteúdo do plan.md, sem texto adicional.`;
43
45
 
44
- export async function generatePlan({ issueNumber }) {
46
+ export async function generatePlan({ issueNumber, force = false }) {
45
47
  const token = await resolveToken();
46
48
  const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/');
47
49
 
@@ -69,13 +71,22 @@ export async function generatePlan({ issueNumber }) {
69
71
  return;
70
72
  }
71
73
 
74
+ // Force: grava a PRÓXIMA versão (plan-v2.md…) preservando as anteriores; sem
75
+ // force, sobrescreve a versão vigente. Ver lib/feature-docs.mjs.
76
+ const forced = isForced({ labels: issue.labels || [], flag: force });
77
+ await consumeForceLabel(token, owner, repo, parseInt(issueNumber, 10));
78
+
72
79
  const slug = slugify(issue.title);
73
80
  const featureDir = `docs/features/${slug}`;
74
- const filePath = `${featureDir}/plan.md`;
81
+ const target = resolveWritePath(featureDir, 'plan', { force: forced });
82
+ const filePath = target.path;
83
+ if (forced) console.log(`Modo forçado ativo — gerando ${filePath} (v${target.version}), sem tocar nas versões anteriores.`);
75
84
 
76
- // Read existing spec.md if available (spec é gerada antes do plano)
77
- const specPath = `${featureDir}/spec.md`;
78
- const specContent = existsSync(specPath) ? readFileSync(specPath, 'utf-8') : null;
85
+ // Spec ATUAL (maior versão) como contexto — a spec é gerada antes do plano.
86
+ const spec = resolveDoc(featureDir, 'spec');
87
+ const specPath = spec?.path || `${featureDir}/spec.md`;
88
+ const specContent = spec ? readFileSync(spec.path, 'utf-8') : null;
89
+ if (spec) console.log(`Usando ${spec.path} (spec v${spec.version}) como contexto.`);
79
90
 
80
91
  // Tech context (RFC-002 §4): estático + dinâmico + override do corpo da issue.
81
92
  const tech = buildTechContext({ issueBody: issue.body || '' });
@@ -113,7 +124,7 @@ export async function generatePlan({ issueNumber }) {
113
124
  git(`git config user.email "spec-wave[bot]@github.com"`);
114
125
  git(`git config user.name "spec-wave[bot]"`);
115
126
  git(`git add "${filePath}"`);
116
- git(`git commit -m "docs: generate plan.md for ${slug} [spec-wave]"`);
127
+ git(`git commit -m "docs: generate plan v${target.version} for ${slug} [spec-wave]"`);
117
128
  git('git pull --rebase');
118
129
  git('git push');
119
130
 
@@ -123,8 +134,12 @@ export async function generatePlan({ issueNumber }) {
123
134
  // Comment on issue
124
135
  await commentOnIssue(
125
136
  token, owner, repo, parseInt(issueNumber, 10),
126
- `📋 **plan.md gerado automaticamente!**\n\n` +
137
+ `📋 **plano técnico gerado automaticamente** (v${target.version})\n\n` +
127
138
  `📄 Arquivo: [\`${filePath}\`](https://github.com/${owner}/${repo}/blob/main/${filePath})\n\n` +
139
+ (target.isNewVersion
140
+ ? `♻️ Regeração forçada: as versões anteriores foram **preservadas**; ` +
141
+ `**v${target.version} passa a ser o plano atual** — é ele que a validação e a decomposição vão ler.\n\n`
142
+ : '') +
128
143
  `Revise o plano e, quando estiver pronto, valide a Feature: mova o card para **✅ Ready** ou use:\n` +
129
144
  `\`\`\`\ngh issue edit ${issueNumber} --add-label "spec-wave:ready"\n\`\`\`` +
130
145
  formatLintWarning(lintFindings)
@@ -5,6 +5,8 @@ import { getIssue, removeLabel, commentOnIssue } from '../api/github-rest.mjs';
5
5
  import { generateDocument } from '../lib/claude.mjs';
6
6
  import { recordUsage } from '../lib/usage-report.mjs';
7
7
  import { slugify } from '../lib/slugify.mjs';
8
+ import { isForced, consumeForceLabel } from '../lib/force.mjs';
9
+ import { resolveWritePath } from '../lib/feature-docs.mjs';
8
10
  import { detectIssueType } from '../lib/issue-type.mjs';
9
11
  import { allowsSpecPlan, SPEC_PLAN_EXCLUDED_TYPES, TARGET_LANGUAGE } from '../config.mjs';
10
12
 
@@ -41,7 +43,7 @@ Regras:
41
43
  - Seja específico e detalhado em cada seção.
42
44
  - Responda APENAS com o conteúdo do spec.md, sem texto adicional.`;
43
45
 
44
- export async function generateSpec({ issueNumber }) {
46
+ export async function generateSpec({ issueNumber, force = false }) {
45
47
  const token = await resolveToken();
46
48
  const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/');
47
49
 
@@ -70,9 +72,17 @@ export async function generateSpec({ issueNumber }) {
70
72
  return;
71
73
  }
72
74
 
75
+ // Force (flag --force ou label spec-wave:force): em vez de sobrescrever,
76
+ // grava a PRÓXIMA versão (spec-v2.md, spec-v3.md…) e ela passa a ser o
77
+ // documento atual. Sem force, sobrescreve a versão vigente.
78
+ const forced = isForced({ labels: issue.labels || [], flag: force });
79
+ await consumeForceLabel(token, owner, repo, parseInt(issueNumber, 10));
80
+
73
81
  const slug = slugify(issue.title);
74
82
  const featureDir = `docs/features/${slug}`;
75
- const filePath = `${featureDir}/spec.md`;
83
+ const target = resolveWritePath(featureDir, 'spec', { force: forced });
84
+ const filePath = target.path;
85
+ if (forced) console.log(`Modo forçado ativo — gerando ${filePath} (v${target.version}), sem tocar nas versões anteriores.`);
76
86
 
77
87
  // Payload estruturado (RFC-002 §5.1): metadata + entrada de negócio.
78
88
  const payload = {
@@ -107,7 +117,7 @@ export async function generateSpec({ issueNumber }) {
107
117
  git(`git config user.email "spec-wave[bot]@github.com"`);
108
118
  git(`git config user.name "spec-wave[bot]"`);
109
119
  git(`git add "${filePath}"`);
110
- git(`git commit -m "docs: generate spec.md for ${slug} [spec-wave]"`);
120
+ git(`git commit -m "docs: generate spec v${target.version} for ${slug} [spec-wave]"`);
111
121
  git('git pull --rebase');
112
122
  git('git push');
113
123
 
@@ -117,8 +127,12 @@ export async function generateSpec({ issueNumber }) {
117
127
  // Comment on issue
118
128
  await commentOnIssue(
119
129
  token, owner, repo, parseInt(issueNumber, 10),
120
- `📋 **spec.md gerado automaticamente!**\n\n` +
130
+ `📋 **spec gerada automaticamente** (v${target.version})\n\n` +
121
131
  `📄 Arquivo: [\`${filePath}\`](https://github.com/${owner}/${repo}/blob/main/${filePath})\n\n` +
132
+ (target.isNewVersion
133
+ ? `♻️ Regeração forçada: as versões anteriores foram **preservadas**; ` +
134
+ `**v${target.version} passa a ser a spec atual** — é ela que o plano, a validação e a decomposição vão ler.\n\n`
135
+ : '') +
122
136
  `Revise a especificação e, quando estiver pronto, gere o plano técnico: mova o card para **📋 Plan** ou use:\n` +
123
137
  `\`\`\`\ngh issue edit ${issueNumber} --add-label "spec-wave:plan"\n\`\`\`` +
124
138
  formatLintWarning(lintFindings)
@@ -14,6 +14,7 @@ import { detectIssueType } from '../lib/issue-type.mjs';
14
14
  import { slugify } from '../lib/slugify.mjs';
15
15
  import { parseDependencies, orderStories, formatDependencyLine } from '../lib/dependencies.mjs';
16
16
  import { loadProjectConfig, resolveField } from '../lib/board.mjs';
17
+ import { resolveDoc } from '../lib/feature-docs.mjs';
17
18
  import { planBoardMoves, applyBoardMoves } from '../lib/implement-board.mjs';
18
19
  import { extractPathsFromPlan, buildCodeDigest } from '../lib/code-digest.mjs';
19
20
 
@@ -41,15 +42,16 @@ async function resolveFeature(token, startNodeId) {
41
42
  return null;
42
43
  }
43
44
 
44
- // Lê spec.md/plan.md de um docs/features/<slug> se existirem.
45
+ // Lê os documentos ATUAIS (maior versão) de um docs/features/<slug>: com
46
+ // regerações forçadas o vigente é spec-v2.md/plan-v3.md…, não o original.
45
47
  function readSpecPlan(featureDir) {
46
- const specPath = path.join(featureDir, 'spec.md');
47
- const planPath = path.join(featureDir, 'plan.md');
48
+ const spec = resolveDoc(featureDir, 'spec');
49
+ const plan = resolveDoc(featureDir, 'plan');
48
50
  return {
49
- specPath,
50
- planPath,
51
- spec: existsSync(specPath) ? readFileSync(specPath, 'utf-8') : null,
52
- plan: existsSync(planPath) ? readFileSync(planPath, 'utf-8') : null,
51
+ specPath: spec?.path || path.join(featureDir, 'spec.md'),
52
+ planPath: plan?.path || path.join(featureDir, 'plan.md'),
53
+ spec: spec ? readFileSync(spec.path, 'utf-8') : null,
54
+ plan: plan ? readFileSync(plan.path, 'utf-8') : null,
53
55
  };
54
56
  }
55
57
 
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { resolveToken } from '../api/auth.mjs';
4
4
  import { getIssue, removeLabel, addLabel, commentOnIssue } from '../api/github-rest.mjs';
5
5
  import { slugify } from '../lib/slugify.mjs';
6
+ import { resolveDoc } from '../lib/feature-docs.mjs';
6
7
  import { CONFIG_FILE, LABEL_CRITIQUE_FAILED, REQUIRED_PLAN_SECTIONS, REQUIRED_SPEC_SECTIONS } from '../config.mjs';
7
8
  import { findIncompleteDocSigns } from '../lib/doc-completeness.mjs';
8
9
 
@@ -39,37 +40,39 @@ export async function validate({ issueNumber }) {
39
40
  );
40
41
  }
41
42
 
42
- // Check plan.md
43
- const planPath = `${featureDir}/plan.md`;
44
- if (!existsSync(planPath)) {
43
+ // Valida sempre o documento ATUAL de cada tipo — com regerações forçadas a
44
+ // versão vigente é a maior (plan-v2.md, plan-v3.md…), não o plan.md original.
45
+ const plan = resolveDoc(featureDir, 'plan');
46
+ const planPath = plan?.path || `${featureDir}/plan.md`;
47
+ if (!plan) {
45
48
  errors.push('❌ `plan.md` não encontrado em `' + planPath + '`');
46
49
  } else {
47
- const planContent = readFileSync(planPath, 'utf-8');
50
+ const planContent = readFileSync(plan.path, 'utf-8');
48
51
  for (const section of REQUIRED_PLAN_SECTIONS) {
49
52
  if (!planContent.includes(`# ${section}`)) {
50
- errors.push(`❌ Seção obrigatória ausente no plan.md: **${section}**`);
53
+ errors.push(`❌ Seção obrigatória ausente no plano (v${plan.version}): **${section}**`);
51
54
  }
52
55
  }
53
56
  for (const problem of findIncompleteDocSigns(planContent)) {
54
- errors.push(`❌ \`plan.md\` parece incompleto: ${problem}`);
57
+ errors.push(`❌ \`${planPath}\` parece incompleto: ${problem}`);
55
58
  }
56
59
  }
57
60
 
58
- // Check spec.md
59
- const specPath = `${featureDir}/spec.md`;
60
- if (!existsSync(specPath)) {
61
+ const spec = resolveDoc(featureDir, 'spec');
62
+ const specPath = spec?.path || `${featureDir}/spec.md`;
63
+ if (!spec) {
61
64
  errors.push('❌ `spec.md` não encontrado em `' + specPath + '`');
62
65
  } else {
63
- const specContent = readFileSync(specPath, 'utf-8');
66
+ const specContent = readFileSync(spec.path, 'utf-8');
64
67
  for (const section of REQUIRED_SPEC_SECTIONS) {
65
68
  if (!specContent.includes(`# ${section}`)) {
66
- errors.push(`❌ Seção obrigatória ausente no spec.md: **${section}**`);
69
+ errors.push(`❌ Seção obrigatória ausente na spec (v${spec.version}): **${section}**`);
67
70
  }
68
71
  }
69
72
  // Seções presentes não garantem documento completo: um corte dentro da
70
73
  // última seção passa na checagem acima (foi o caso da EP2-F13).
71
74
  for (const problem of findIncompleteDocSigns(specContent)) {
72
- errors.push(`❌ \`spec.md\` parece incompleto: ${problem}`);
75
+ errors.push(`❌ \`${specPath}\` parece incompleto: ${problem}`);
73
76
  }
74
77
  }
75
78
 
package/src/config.mjs CHANGED
@@ -183,6 +183,10 @@ export const PRIORITY_LABELS = [
183
183
  // Labels de estado gravadas pelas automações (não são gatilhos do usuário).
184
184
  export const LABEL_CRITIQUE_FAILED = 'spec-wave:critique-failed';
185
185
  export const LABEL_DECOMPOSED = 'spec-wave:decomposed';
186
+ // Modificador (não é gatilho — sozinha não dispara workflow nenhum): quando
187
+ // presente junto de uma label de gatilho, manda o comando re-executar a etapa
188
+ // ignorando os guards. É consumida (removida) pelo run que a leu.
189
+ export const LABEL_FORCE = 'spec-wave:force';
186
190
 
187
191
  export const TRIGGER_LABELS = [
188
192
  { name: 'spec-wave:spec', color: 'BFD4F2', description: 'Gerar spec.md via GitHub Action' },
@@ -192,6 +196,7 @@ export const TRIGGER_LABELS = [
192
196
  { name: 'spec-wave:decompose', color: 'BFD4F2', description: 'Decompor em Stories e Tasks' },
193
197
  { name: LABEL_CRITIQUE_FAILED, color: 'B60205', description: 'Crítica adversarial apontou contradições graves' },
194
198
  { name: LABEL_DECOMPOSED, color: 'EDEDED', description: 'Feature já decomposta em Stories e Tasks' },
199
+ { name: LABEL_FORCE, color: 'D93F0B', description: 'Re-executa a etapa ignorando os guards (consumida no run)' },
195
200
  ];
196
201
 
197
202
  export const ALL_LABELS = [...TYPE_LABELS, ...PRIORITY_LABELS, ...TRIGGER_LABELS];
@@ -0,0 +1,89 @@
1
+ // Resolução dos documentos versionados de uma Feature (docs/features/<slug>/).
2
+ //
3
+ // A primeira geração escreve `spec.md`/`plan.md` (= v1). Cada regeração
4
+ // FORÇADA (flag --force ou label spec-wave:force) cria a próxima versão em vez
5
+ // de sobrescrever: `spec-v2.md`, `spec-v3.md`, … O documento ATUAL é sempre a
6
+ // MAIOR versão — é ele que validate/generate-plan/decompose/implement leem, e
7
+ // é ele que uma regeração sem force sobrescreve.
8
+ import { existsSync, readdirSync } from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ /**
12
+ * Versão de um arquivo de documento (função PURA).
13
+ * `spec.md` → 1; `spec-v2.md` → 2. Outro nome → null.
14
+ *
15
+ * @param {string} filename nome do arquivo (sem diretório)
16
+ * @param {string} base 'spec' ou 'plan'
17
+ * @returns {number|null}
18
+ */
19
+ export function parseDocVersion(filename, base) {
20
+ const match = new RegExp(`^${base}(?:-v(\\d+))?\\.md$`).exec(String(filename ?? ''));
21
+ if (!match) return null;
22
+ return match[1] ? parseInt(match[1], 10) : 1;
23
+ }
24
+
25
+ /**
26
+ * Documento ATUAL entre os arquivos dados: o de maior versão (função PURA).
27
+ * Empate (raro: `spec.md` e `spec-v1.md` coexistindo) resolve pelo sufixado,
28
+ * para o resultado ser determinístico.
29
+ *
30
+ * @param {string[]} filenames
31
+ * @param {string} base 'spec' ou 'plan'
32
+ * @returns {{ file: string, version: number }|null}
33
+ */
34
+ export function pickLatestDoc(filenames, base) {
35
+ const found = (filenames || [])
36
+ .map(file => ({ file, version: parseDocVersion(file, base), suffixed: /-v\d+\.md$/.test(file) }))
37
+ .filter(entry => entry.version !== null)
38
+ .sort((a, b) => (a.version - b.version) || (Number(a.suffixed) - Number(b.suffixed)));
39
+ if (found.length === 0) return null;
40
+ const { file, version } = found[found.length - 1];
41
+ return { file, version };
42
+ }
43
+
44
+ /**
45
+ * Nome do arquivo da PRÓXIMA versão (função PURA): `spec.md` quando ainda não
46
+ * há nenhum, `spec-v<N+1>.md` a partir da maior versão existente.
47
+ *
48
+ * @param {string[]} filenames
49
+ * @param {string} base 'spec' ou 'plan'
50
+ * @returns {string}
51
+ */
52
+ export function nextDocName(filenames, base) {
53
+ const latest = pickLatestDoc(filenames, base);
54
+ return latest ? `${base}-v${latest.version + 1}.md` : `${base}.md`;
55
+ }
56
+
57
+ // Nomes de arquivo do diretório da feature ([] se ele ainda não existe).
58
+ function readDir(featureDir) {
59
+ return featureDir && existsSync(featureDir) ? readdirSync(featureDir) : [];
60
+ }
61
+
62
+ /**
63
+ * Caminho do documento ATUAL (maior versão) — null se nenhum existe.
64
+ *
65
+ * @returns {{ path: string, version: number }|null}
66
+ */
67
+ export function resolveDoc(featureDir, base) {
68
+ const latest = pickLatestDoc(readDir(featureDir), base);
69
+ return latest ? { path: path.join(featureDir, latest.file), version: latest.version } : null;
70
+ }
71
+
72
+ /**
73
+ * Caminho onde a geração deve escrever.
74
+ * - `force: true` → próxima versão (nunca sobrescreve nada);
75
+ * - `force: false` → o documento atual (sobrescreve), ou `<base>.md` no 1º run.
76
+ *
77
+ * @returns {{ path: string, version: number, isNewVersion: boolean }}
78
+ */
79
+ export function resolveWritePath(featureDir, base, { force = false } = {}) {
80
+ const files = readDir(featureDir);
81
+ if (!force) {
82
+ const latest = pickLatestDoc(files, base);
83
+ return latest
84
+ ? { path: path.join(featureDir, latest.file), version: latest.version, isNewVersion: false }
85
+ : { path: path.join(featureDir, `${base}.md`), version: 1, isNewVersion: false };
86
+ }
87
+ const name = nextDocName(files, base);
88
+ return { path: path.join(featureDir, name), version: parseDocVersion(name, base), isNewVersion: true };
89
+ }
@@ -0,0 +1,34 @@
1
+ // Detecção do modo "force" (re-executar a etapa ignorando os guards).
2
+ //
3
+ // Os comandos generate-spec/generate-plan/decompose rodam dentro de Actions
4
+ // disparadas por label — a linha de comando do workflow é fixa e não tem como
5
+ // receber uma flag. Por isso o force chega de duas formas equivalentes:
6
+ // • label `spec-wave:force` na issue (fluxo normal, pelo board);
7
+ // • flag `--force` (execução local/manual da CLI).
8
+ // A label é CONSUMIDA pelo run que a leu (ver consumeForceLabel), senão ela
9
+ // ficaria pendurada e forçaria silenciosamente todos os runs seguintes.
10
+ import { LABEL_FORCE } from '../config.mjs';
11
+ import { removeLabel } from '../api/github-rest.mjs';
12
+
13
+ /**
14
+ * Diz se a etapa deve ser re-executada ignorando os guards (função PURA).
15
+ *
16
+ * @param {object} [params]
17
+ * @param {Array<string|{name: string}>} [params.labels] labels da issue
18
+ * @param {boolean} [params.flag] valor da flag `--force` da CLI
19
+ * @returns {boolean}
20
+ */
21
+ export function isForced({ labels = [], flag = false } = {}) {
22
+ if (flag) return true;
23
+ return (labels || [])
24
+ .map(l => (typeof l === 'string' ? l : l?.name))
25
+ .includes(LABEL_FORCE);
26
+ }
27
+
28
+ /**
29
+ * Remove a label `spec-wave:force` da issue. Best-effort: nunca lança — deixar
30
+ * de consumir a label é um incômodo, não um motivo para derrubar o comando.
31
+ */
32
+ export async function consumeForceLabel(token, owner, repo, issueNumber) {
33
+ await removeLabel(token, owner, repo, issueNumber, LABEL_FORCE).catch(() => {});
34
+ }
@@ -105,6 +105,16 @@ Labels de **estado** (gravadas pelas automações — **não** são gatilhos, n
105
105
  - `spec-wave:critique-failed` → a crítica adversarial apontou contradições **graves** nos documentos; **bloqueia** o `spec-wave:ready` até ser removida (veja *Crítica adversarial* abaixo)
106
106
  - `spec-wave:decomposed` → a Feature/RFC já foi decomposta; o `decompose` pula silenciosamente enquanto ela existir (veja *Guard de idempotência* abaixo)
107
107
 
108
+ Label **modificadora** — `spec-wave:force`: sozinha **não dispara nada**; adicionada **junto** de uma label de gatilho, manda o comando **re-executar a etapa ignorando os guards**. É **consumida** (removida) pelo run que a leu, então vale para uma execução só. Adicione-a **antes ou junto** do gatilho, nunca depois (o evento do gatilho já teria disparado):
109
+ ```bash
110
+ gh issue edit <n> --add-label "spec-wave:force" --add-label "spec-wave:decompose"
111
+ ```
112
+ Efeito por comando:
113
+ - **spec** e **plan** → geram a **próxima versão** em vez de sobrescrever: `spec.md` (v1) → `spec-v2.md` → `spec-v3.md`… As versões anteriores ficam intactas e a **maior versão passa a ser o documento atual** (veja *Documentos versionados*).
114
+ - **decompose** → ignora o guard e **fecha as sub-issues da decomposição anterior** antes de gerar as novas (veja *Guard de idempotência*).
115
+
116
+ Sem a label, `spec`/`plan` **sobrescrevem o documento atual** (comportamento de sempre).
117
+
108
118
  A etapa **🚧 Desenvolvimento** é coberta pelo comando **local** `npx @spec-wave/cli@latest implement <número>` (não é uma label/Action): lê uma **Feature** (todas as Stories pendentes, em ordem de dependência), uma Story ou uma Task e aciona o spec-kit para implementar. Veja `/spec-wave implement`.
109
119
 
110
120
  ---
@@ -183,6 +193,7 @@ Mesmas flags do `issue` (exceto `--type`, fixo em `feature`). Mantido para o flu
183
193
  | Flag | Tipo | Descrição |
184
194
  |------|------|-----------|
185
195
  | `--issue-number <n>` | string (obrigatório) | Número da issue no GitHub. |
196
+ | `--force` | flag | Re-executa a etapa ignorando os guards. No fluxo por label o equivalente é a label `spec-wave:force` (a linha de comando do workflow é fixa) — prefira a label. |
186
197
 
187
198
  > ⚠️ Esses quatro comandos são executados pelos **GitHub Actions** (disparados por labels), **não** pela skill diretamente. Veja a *Regra fundamental*: para gerar plan/spec/decompor, adicione a **label** correspondente — não rode o comando à mão (a não ser para debug local).
188
199
  >
@@ -239,11 +250,31 @@ Após o `generate-plan` e **antes** da criação de issues no `decompose`, um se
239
250
  - **Fluxo de resolução:** (1) leia o comentário 🔎 na issue; (2) corrija `spec.md`/`plan.md` (regenere com as labels ou edite e commite); (3) remova a label: `gh issue edit <n> --remove-label "spec-wave:critique-failed"`; (4) re-aplique `spec-wave:ready` para validar de novo.
240
251
  - Findings leves não bloqueiam — trate-os como revisão de qualidade.
241
252
 
253
+ ### Documentos versionados (`spec-v2.md`, `plan-v3.md`…)
254
+
255
+ A primeira geração escreve `spec.md` e `plan.md` — a **v1**. Cada regeração **forçada** (label `spec-wave:force`) grava a **próxima versão** ao lado, sem tocar nas anteriores:
256
+
257
+ ```
258
+ docs/features/<slug>/
259
+ spec.md ← v1
260
+ spec-v2.md ← 1ª regeração forçada
261
+ spec-v3.md ← 2ª regeração forçada ⬅ spec ATUAL
262
+ plan.md ⬅ plano ATUAL (spec e plan versionam independente)
263
+ ```
264
+
265
+ **A maior versão é sempre o documento atual** — é ela que o `generate-plan` usa como contexto, que o `validate` valida, que o `decompose` lê e que o `implement` anexa. Uma regeração **sem** force sobrescreve essa versão atual (não volta para o `spec.md` original).
266
+
267
+ Ao mostrar os documentos ao usuário, use a versão atual; ofereça as anteriores só se ele quiser comparar. Nunca renomeie nem apague versões antigas por conta própria — elas são o histórico da Feature.
268
+
242
269
  ### Guard de idempotência do decompose (`spec-wave:decomposed`)
243
270
 
244
271
  O evento `labeled` pode redisparar (re-add da label, retry de runner). Para não duplicar Stories/Tasks, o `decompose` **pula** quando a issue já tem a label **`spec-wave:decomposed`** ou já tem sub-issues do tipo-alvo (`[STORY]` para Feature, `[TASK]` para RFC). Ao concluir com sucesso, o Action grava a label. Os workflows ainda usam `concurrency` por issue para serializar runs simultâneos.
245
272
 
246
- **Para forçar um re-decompose:** remova a label (`gh issue edit <n> --remove-label "spec-wave:decomposed"`), **apague/feche as sub-issues antigas** (senão a detecção por sub-issues pula de novo) e re-adicione `spec-wave:decompose`.
273
+ **Para forçar um re-decompose**, adicione a label `spec-wave:force` junto com o gatilho:
274
+ ```bash
275
+ gh issue edit <n> --add-label "spec-wave:force" --add-label "spec-wave:decompose"
276
+ ```
277
+ O comando então: (1) ignora os dois guards; (2) **fecha** as sub-issues abertas do tipo-alvo da decomposição anterior **e os filhos delas** (as Tasks de cada Story), comentando na issue o que foi fechado; (3) gera a nova decomposição. **É destrutivo** — se alguma Story já tiver saído de ✅ Ready, o comentário destaca quais, para você conferir se descartou trabalho em andamento. Confirme com o usuário antes de acionar em Features com desenvolvimento em curso.
247
278
 
248
279
  ### Dependências entre Stories (`Depende de: #N`)
249
280
 
@@ -405,7 +436,11 @@ Inicia a geração da **especificação funcional** para uma Feature. É o **pri
405
436
  ```
406
437
  3. Informe: "Label `spec-wave:spec` adicionada. O GitHub Action `generate-spec.yml` irá gerar o `spec.md` automaticamente."
407
438
  4. Após a conclusão, ofereça revisar o spec.md gerado em `docs/features/<slug>/spec.md`.
408
- 5. Próximo passo: gerar o plano técnico mova para **📋 Plan** e use `/spec-wave plan <número>`.
439
+ 5. **Para regerar** (a spec já existe e o usuário quer outra versão): adicione `spec-wave:force` junto com o gatilhoo Action grava a **próxima versão** (`spec-v2.md`, `spec-v3.md`…) e preserva as anteriores; a nova passa a ser a spec atual. Sem o force, a versão atual é **sobrescrita** (edições manuais se perdem) — avise antes de acionar assim.
440
+ ```bash
441
+ gh issue edit <número> --add-label "spec-wave:force" --add-label "spec-wave:spec"
442
+ ```
443
+ 6. Próximo passo: gerar o plano técnico — mova para **📋 Plan** e use `/spec-wave plan <número>`.
409
444
 
410
445
  ---
411
446
 
@@ -424,7 +459,8 @@ O plano técnico segue o schema do RFC-002 §3.2: **Estratégia Técnica** (com
424
459
  ```
425
460
  4. Informe: "Label `spec-wave:plan` adicionada. O GitHub Action `generate-plan.yml` irá gerar o `plan.md` automaticamente. Acompanhe em: Actions → Generate Plan."
426
461
  5. Após a conclusão (cheque comentários na issue ou aguarde confirmação do usuário), ofereça revisar o plan.md gerado em `docs/features/<slug>/plan.md`.
427
- 6. Próximo passo: validar a Featuremova para **✅ Ready** e use `/spec-wave ready <número>`.
462
+ 6. **Para regerar**: adicione `spec-wave:force` junto com `spec-wave:plan`grava `plan-v2.md`, `plan-v3.md`… preservando as anteriores. Sem o force, o plano atual é sobrescrito (confirme antes).
463
+ 7. Próximo passo: validar a Feature — mova para **✅ Ready** e use `/spec-wave ready <número>`.
428
464
 
429
465
  ---
430
466
 
@@ -516,7 +552,7 @@ Para qualquer outro tipo (Spike, Bug, Story, Task, …) o Action **recusa** e co
516
552
  ```
517
553
  3. Informe: "Decomposição iniciada — Feature gera Stories+Tasks; RFC gera Tasks."
518
554
  4. Após a conclusão, as issues filhas aparecerão como comentário na issue pai, junto com o comentário 🔎 da crítica adversarial. A issue pai e as Stories/Tasks criadas entram no board na Etapa **✅ Ready** (Status Todo; a Etapa nunca retrocede — itens já adiante não são tocados). As Stories geradas trazem a linha `Depende de: #N` (+ relação *blocked by*) — use `npx @spec-wave/cli@latest order <número>` para ver a ordem de execução.
519
- 5. A issue recebe a label `spec-wave:decomposed` (guard de idempotência): rodar de novo **não** duplica as issues. Para forçar um re-decompose, siga a seção *Guard de idempotência*.
555
+ 5. A issue recebe a label `spec-wave:decomposed` (guard de idempotência): rodar de novo **não** duplica as issues. Para re-decompor, adicione `spec-wave:force` junto com o gatilho — isso **fecha** a decomposição anterior antes de gerar a nova (veja *Guard de idempotência*; confirme com o usuário, é destrutivo).
520
556
 
521
557
  ---
522
558
 
@@ -682,8 +718,12 @@ Audita um Pull Request e corrige automaticamente os problemas encontrados — se
682
718
  docs/
683
719
  features/
684
720
  <slug-da-feature>/
685
- spec.md ← gerado pelo GitHub Action quando spec-wave:spec é adicionado (1º)
686
- plan.md ← gerado pelo GitHub Action quando spec-wave:plan é adicionado (2º, usa a spec)
721
+ spec.md ← gerado quando spec-wave:spec é adicionado (1º) = v1
722
+ spec-v2.md ← regeração forçada (spec-wave:force); v1 preservada
723
+ plan.md ← gerado quando spec-wave:plan é adicionado (2º, usa a spec ATUAL) = v1
724
+ plan-v2.md ← regeração forçada do plano
687
725
  ```
688
726
 
727
+ A **maior versão de cada tipo é o documento atual** (aqui: `spec-v2.md` e `plan-v2.md`) — veja *Documentos versionados*.
728
+
689
729
  O slug é gerado a partir do título da issue: `[FEATURE] Cadastro de Pedidos com PIX` → `cadastro-de-pedidos-com-pix`