@spec-wave/cli 0.25.0 → 0.27.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 (38) hide show
  1. package/bin/spec-wave.mjs +4 -374
  2. package/package.json +1 -1
  3. package/src/api/github-rest.mjs +25 -0
  4. package/src/cli.mjs +400 -0
  5. package/src/commands/decompose.mjs +186 -45
  6. package/src/commands/doctor.mjs +190 -3
  7. package/src/commands/generate-bug.mjs +22 -16
  8. package/src/commands/generate-plan.mjs +72 -23
  9. package/src/commands/generate-spec.mjs +19 -15
  10. package/src/commands/implement.mjs +40 -20
  11. package/src/commands/mode.mjs +16 -5
  12. package/src/commands/run.mjs +156 -40
  13. package/src/commands/validate.mjs +84 -17
  14. package/src/config.mjs +18 -0
  15. package/src/lib/artifact-pr.mjs +272 -0
  16. package/src/lib/artifact-publish.mjs +169 -0
  17. package/src/lib/doc-availability.mjs +23 -1
  18. package/src/lib/doc-source.mjs +162 -0
  19. package/src/lib/execution-mode.mjs +22 -0
  20. package/src/lib/flow-run.mjs +9 -218
  21. package/src/lib/next-step.mjs +87 -8
  22. package/src/lib/pr-branch.mjs +10 -0
  23. package/src/lib/repo-links.mjs +8 -2
  24. package/src/plugin/.claude-plugin/plugin.json +1 -1
  25. package/src/plugin/skills/bug/SKILL.md +2 -2
  26. package/src/plugin/skills/decompose/SKILL.md +4 -4
  27. package/src/plugin/skills/plan/SKILL.md +1 -1
  28. package/src/plugin/skills/run/SKILL.md +4 -2
  29. package/src/plugin/skills/spec/SKILL.md +4 -4
  30. package/src/plugin/skills/workflow/SKILL.md +2 -2
  31. package/src/templates/skill/SKILL.md +7 -7
  32. package/src/templates/workflows/code-review.yml +13 -2
  33. package/src/templates/workflows/critique.yml +1 -1
  34. package/src/templates/workflows/decompose.yml +13 -2
  35. package/src/templates/workflows/generate-bug.yml +17 -6
  36. package/src/templates/workflows/generate-plan.yml +20 -7
  37. package/src/templates/workflows/generate-spec.yml +20 -7
  38. package/src/templates/workflows/qa.yml +13 -0
@@ -9,26 +9,17 @@
9
9
  // O modo é detectado pelo ambiente (`GITHUB_ACTIONS`), não por flag: um
10
10
  // comando com dois nomes para a mesma coisa envelhece mal.
11
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.
12
+ // **O comportamento é IDÊNTICO nos dois modos** — gera, publica em Pull Request,
13
+ // comenta na issue e avança a Etapa. O board é a fonte de verdade do RFC-001
14
+ // independentemente de onde a geração rodou.
16
15
  //
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. Antes de chegar
26
- // nessa bifurcação, os dois modos repetem pull+push enquanto a rejeição for
27
- // disputa com outro run — ver §publicação concorrente.
16
+ // Este módulo carregou as DUAS exceções que sobravam dessa promessa
17
+ // (identidade do git e falha de push), junto com o commit direto na branch
18
+ // default, o laço de pull+rebase+push e a classificação de corrida. Nada disso
19
+ // existe mais: a publicação passou a ser via API, em branch própria por
20
+ // documento, e mora em lib/artifact-publish.mjs. As duas exceções eram
21
+ // consequência de mexer em git local — some a causa, somem elas.
28
22
 
29
- import { execSync } from 'node:child_process';
30
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
31
- import path from 'node:path';
32
23
  import { resolveRepoContext } from './project-root.mjs';
33
24
  import { CONFIG_FILE } from '../config.mjs';
34
25
 
@@ -72,203 +63,3 @@ export function resolveFlowContext({ cwd = process.cwd(), command = 'este comand
72
63
  }
73
64
  return { owner, repo, root, config, mode };
74
65
  }
75
-
76
- // --- publicação concorrente -------------------------------------------------
77
- //
78
- // `pull --rebase` seguido de `push` NÃO é atômico. Entre os dois cabe o push de
79
- // outro run: dois `generate-spec` de issues DIFERENTES disparados juntos rodam
80
- // em paralelo (a `concurrency` dos workflows é por issue, e é por issue que ela
81
- // tem que ser — serializar o repositório inteiro mataria a vazão), terminam
82
- // juntos e disputam a ponta da branch. O perdedor levava non-fast-forward,
83
- // falhava o job e PERDIA o documento recém-gerado — com a chamada de IA já
84
- // paga. Repetir pull+push resolve: o rebase reaplica o commit sobre a ponta
85
- // nova e o segundo push passa.
86
-
87
- export const PUSH_ATTEMPTS = 5;
88
- const PUSH_BASE_MS = 500;
89
-
90
- // Rejeição por CORRIDA: o remoto andou, e reaplicar por cima resolve.
91
- const RACE_PATTERNS = /non-fast-forward|fetch first|Updates were rejected|cannot lock ref|failed to lock/i;
92
-
93
- // Recusa DELIBERADA do remoto. Compartilha a linha genérica "failed to push
94
- // some refs" com a corrida, então precisa ser testada ANTES: repetir aqui só
95
- // atrasa a mensagem que o usuário precisa ler. Mesma filosofia de
96
- // `isTransientProviderError` (lib/claude.mjs).
97
- const REFUSED_PATTERNS = /GH006|protected branch|pre-receive hook declined|permission to .* denied|403 Forbidden|Authentication failed|could not read Username|Repository not found/i;
98
-
99
- /**
100
- * A saída do git indica disputa pela ponta da branch? (função PURA)
101
- *
102
- * @param {string} output stdout+stderr do comando que falhou
103
- * @returns {boolean}
104
- */
105
- export function isRacePushError(output) {
106
- const text = String(output || '');
107
- if (!text) return false;
108
- if (REFUSED_PATTERNS.test(text)) return false;
109
- return RACE_PATTERNS.test(text);
110
- }
111
-
112
- /**
113
- * Espera antes da próxima tentativa (função PURA).
114
- *
115
- * Exponencial COM jitter: sem o jitter, dois runs que colidiram uma vez voltam
116
- * a colidir no mesmo instante — o backoff determinístico os mantém em fase.
117
- *
118
- * @param {number} attempt tentativa que acabou de falhar (1-based)
119
- * @param {object} [opts]
120
- * @param {number} [opts.baseMs]
121
- * @param {() => number} [opts.random] injetável para o teste
122
- * @returns {number} milissegundos
123
- */
124
- export function pushBackoffMs(attempt, { baseMs = PUSH_BASE_MS, random = Math.random } = {}) {
125
- const teto = baseMs * 2 ** (attempt - 1); // 500ms, 1s, 2s, 4s…
126
- return Math.round(teto * (1 + random())); // [teto, 2*teto)
127
- }
128
-
129
- // Sleep BLOQUEANTE de propósito: `commitGenerated` é síncrona de ponta a ponta
130
- // (execSync), e um único `await` no meio obrigaria os três comandos chamadores
131
- // e a suíte inteira a virarem async para nada — nada mais roda nesta thread
132
- // enquanto o git trabalha.
133
- function sleepSync(ms) {
134
- if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
135
- }
136
-
137
- /**
138
- * Roda um comando git capturando a saída, e a devolve anexada ao erro.
139
- *
140
- * Com `stdio: 'inherit'` o texto do git vai para o log mas NÃO chega ao
141
- * processo: `err.message` é só "Command failed: git push", e não dá para
142
- * distinguir corrida de branch protegida. Capturando, o log continua igual
143
- * (reemitimos) e a classificação passa a ser possível.
144
- */
145
- function gitCaptured(cmd) {
146
- try {
147
- const out = execSync(cmd, { stdio: ['ignore', 'pipe', 'pipe'] });
148
- if (out?.length) process.stdout.write(out);
149
- return;
150
- } catch (err) {
151
- const detail = [err.stdout, err.stderr].map(b => b?.toString() || '').join('').trim();
152
- if (detail) process.stderr.write(`${detail}\n`);
153
- const resumo = detail.split('\n').find(l => l.trim()) || err.message;
154
- const erro = new Error(`${cmd} falhou: ${resumo}`);
155
- erro.gitOutput = detail;
156
- throw erro;
157
- }
158
- }
159
-
160
- // Rebase interrompido (conflito, ou pull abortado no meio) deixa o repositório
161
- // EM rebase. No runner descartável tanto faz; no clone do usuário, sair assim
162
- // é deixar um estado que ele não pediu e talvez nem perceba.
163
- function abortRebaseIfAny() {
164
- try {
165
- const dir = execSync('git rev-parse --git-path rebase-merge', { stdio: 'pipe' }).toString().trim();
166
- const dirApply = execSync('git rev-parse --git-path rebase-apply', { stdio: 'pipe' }).toString().trim();
167
- if (!existsSync(dir) && !existsSync(dirApply)) return;
168
- execSync('git rebase --abort', { stdio: 'pipe' });
169
- } catch {
170
- // Melhor esforço: se nem abortar dá, o erro original é o que importa.
171
- }
172
- }
173
-
174
- /**
175
- * pull --rebase + push, repetindo enquanto a falha for disputa pela branch.
176
- *
177
- * @returns {{attempts: number}}
178
- * @throws o erro do git quando não é corrida, ou quando as tentativas acabam
179
- */
180
- function publishWithRetry({ attempts, baseMs }) {
181
- for (let attempt = 1; ; attempt++) {
182
- try {
183
- gitCaptured('git pull --rebase');
184
- gitCaptured('git push');
185
- return { attempts: attempt };
186
- } catch (err) {
187
- abortRebaseIfAny();
188
- if (attempt >= attempts || !isRacePushError(err.gitOutput || err.message)) throw err;
189
- const espera = pushBackoffMs(attempt, { baseMs });
190
- console.warn(
191
- `Publicação disputada por outro run (tentativa ${attempt}/${attempts}) — ` +
192
- `repetindo em ${(espera / 1000).toFixed(1)}s.`
193
- );
194
- sleepSync(espera);
195
- }
196
- }
197
- }
198
-
199
- /**
200
- * Grava um arquivo gerado e o publica: commit + pull --rebase + push.
201
- *
202
- * Era o mesmo bloco copiado em `generate-spec`, `generate-plan` e `decompose`,
203
- * com a identidade do bot embutida. Centralizado aqui para que a diferença
204
- * entre os modos exista num lugar só.
205
- *
206
- * O commit é escopado ao caminho (`git commit -- <arquivo>`): sem isso, qualquer
207
- * coisa que você já tivesse no index entraria junto no commit do spec-wave —
208
- * irrelevante num runner limpo, nada irrelevante no seu clone.
209
- *
210
- * @param {object} params
211
- * @param {string} params.filePath caminho absoluto do arquivo
212
- * @param {string} params.content
213
- * @param {string} params.message mensagem de commit
214
- * @param {'actions'|'local'} params.mode
215
- * @param {{attempts?: number, baseMs?: number}} [params.retry] ajuste do laço de
216
- * publicação — existe para o teste não dormir o backoff real
217
- * @returns {{committed: boolean, pushed: boolean, warning: string|null}}
218
- */
219
- export function commitGenerated({ filePath, content, message, mode, retry = {} }) {
220
- mkdirSync(path.dirname(filePath), { recursive: true });
221
- writeFileSync(filePath, content, 'utf-8');
222
-
223
- const git = (cmd, opts = {}) => execSync(cmd, { stdio: 'inherit', ...opts });
224
- const gitQuiet = (cmd) => execSync(cmd, { stdio: 'pipe' }).toString().trim();
225
-
226
- if (mode === 'actions') {
227
- // Runner descartável: identidade do bot é o que se quer no histórico.
228
- git('git config user.email "spec-wave[bot]@github.com"');
229
- git('git config user.name "spec-wave[bot]"');
230
- }
231
-
232
- git(`git add "${filePath}"`);
233
-
234
- // Nada mudou (regerar conteúdo idêntico) → `git commit` sairia 1 e derrubaria
235
- // o comando depois de o trabalho estar feito.
236
- let hasChanges = true;
237
- try {
238
- execSync(`git diff --cached --quiet -- "${filePath}"`, { stdio: 'pipe' });
239
- hasChanges = false;
240
- } catch {
241
- hasChanges = true;
242
- }
243
- if (!hasChanges) {
244
- return { committed: false, pushed: false, warning: 'conteúdo idêntico ao já versionado — nada a commitar' };
245
- }
246
-
247
- git(`git commit -m "${message}" -- "${filePath}"`);
248
-
249
- try {
250
- publishWithRetry({
251
- attempts: retry.attempts ?? PUSH_ATTEMPTS,
252
- baseMs: retry.baseMs ?? PUSH_BASE_MS,
253
- });
254
- return { committed: true, pushed: true, warning: null };
255
- } catch (err) {
256
- if (mode === 'actions') throw err;
257
- // Local: o arquivo está gerado e commitado. Derrubar o comando aqui
258
- // esconderia esse fato atrás de um erro de rede/divergência.
259
- const branch = (() => {
260
- try {
261
- return gitQuiet('git rev-parse --abbrev-ref HEAD');
262
- } catch {
263
- return 'seu branch';
264
- }
265
- })();
266
- return {
267
- committed: true,
268
- pushed: false,
269
- warning:
270
- `commit feito em ${branch}, mas o push falhou (${err.message.split('\n')[0]}). ` +
271
- 'O arquivo está salvo e versionado — publique quando resolver.',
272
- };
273
- }
274
- }
@@ -19,9 +19,11 @@ import {
19
19
  LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN, LABEL_PLAN_APPROVED, LABEL_TRIAGED,
20
20
  LABEL_DUPLICATE, LABEL_WONT_FIX, LABEL_READY, labelNames,
21
21
  } from '../config.mjs';
22
+ import { awaitingMergeBlock } from './artifact-pr.mjs';
23
+ import { isAwaitingMerge } from './doc-source.mjs';
22
24
 
23
25
  /**
24
- * @typedef {'local'|'remote'|'missing'|'unknown'} DocState
26
+ * @typedef {'local'|'remote'|'pending-pr'|'missing'|'unknown'} DocState
25
27
  * @typedef {'generate-spec'|'generate-plan'|'critique'|'validate'|'decompose'
26
28
  * |'decompose-apply'|'generate-bug'|'triage'|'implement'|'none'} StepAction
27
29
  */
@@ -44,11 +46,21 @@ export const STEPS = {
44
46
  types: ['Feature'], creates: false, ai: true,
45
47
  },
46
48
  validate: {
49
+ // `reads` DEPENDE DO TIPO: a validação de Feature abre spec.md e plan.md, a
50
+ // de Bug abre bug.md. Declarar `[]` aqui desarmava o G6 justamente para o
51
+ // passo que só lê do disco — um clone atrasado reprovava uma Feature cujo
52
+ // plan.md existia no remoto, removia `spec-wave:ready` e comentava a falha
53
+ // na issue. Dano a estado COMPARTILHADO por uma condição puramente local.
47
54
  trigger: LABEL_READY, cli: 'validate', writes: null, reads: [],
55
+ readsByType: { Feature: ['spec', 'plan'], Bug: ['bug'] },
48
56
  types: ['Feature', 'Bug'], creates: false, ai: false,
49
57
  },
50
58
  decompose: {
59
+ // O rascunho de Feature é montado a partir de spec.md + plan.md; o de RFC
60
+ // não usa nenhum dos dois. Com `[]` e o plan só no remoto, o decompose
61
+ // gerava o rascunho com o plano VAZIO — sem erro, só pior.
51
62
  trigger: LABEL_DECOMPOSE, cli: 'decompose', writes: 'decomposition', reads: [],
63
+ readsByType: { Feature: ['spec', 'plan'], RFC: [] },
52
64
  types: ['Feature', 'RFC'], creates: false, ai: true,
53
65
  },
54
66
  'decompose-apply': {
@@ -71,13 +83,60 @@ export const STEPS = {
71
83
  },
72
84
  };
73
85
 
86
+ /**
87
+ * Documentos que um passo LÊ, para um tipo de item (função PURA).
88
+ *
89
+ * Existe porque dois passos leem coisas diferentes conforme o tipo — `validate`
90
+ * abre spec+plan numa Feature e bug.md num Bug — e o G6 precisa da lista CERTA:
91
+ * ele é o que impede o passo de rodar contra um clone atrasado. Uma lista
92
+ * subdeclarada não causa erro visível, causa o passo rodando com o arquivo
93
+ * errado (ou ausente), que é o modo de falha caro.
94
+ *
95
+ * @param {string} action nome da ação em STEPS
96
+ * @param {string|null} [type] tipo do work item
97
+ * @returns {string[]} documentos lidos
98
+ */
99
+ export function stepReads(action, type) {
100
+ const step = STEPS[action];
101
+ if (!step) return [];
102
+ return step.readsByType?.[type] ?? step.reads;
103
+ }
104
+
105
+ /**
106
+ * Documentos que um passo TOCA (lê ou escreve), para um tipo (função PURA).
107
+ *
108
+ * É a lista que o G6, o aviso de "não deu para consultar o remoto" e a sonda do
109
+ * `run` precisam — os três erravam junto quando `reads` estava subdeclarado.
110
+ *
111
+ * @param {string} action
112
+ * @param {string|null} [type]
113
+ * @returns {string[]}
114
+ */
115
+ export function stepDocs(action, type) {
116
+ return [STEPS[action]?.writes, ...stepReads(action, type)].filter(Boolean);
117
+ }
118
+
119
+ /**
120
+ * Documentos que um tipo usa (função PURA).
121
+ *
122
+ * O `run` precisa disto para sondar o remoto quando a decisão veio BLOQUEADA e
123
+ * o passo é `none` — aí não há `writes`/`reads` de onde tirar a lista, e é
124
+ * justamente o caso em que o G5 afirma "não está no clone nem no remoto".
125
+ *
126
+ * @param {string|null} [type]
127
+ * @returns {string[]}
128
+ */
129
+ export function docsForType(type) {
130
+ return DOCS_BY_TYPE[type] || [];
131
+ }
132
+
74
133
  /** Tipos que o `run` sabe conduzir. Story/Task/Epic/Spike não têm passo de documento. */
75
134
  export const RUNNABLE_TYPES = ['Feature', 'Bug', 'RFC'];
76
135
 
77
136
  const TRIGGERS = Object.values(STEPS).map(s => s.trigger).filter(Boolean);
78
137
 
79
138
  /** Documentos que cada tipo usa, na ordem em que o fluxo os produz. */
80
- const DOCS_BY_TYPE = {
139
+ export const DOCS_BY_TYPE = {
81
140
  Feature: ['spec', 'plan', 'decomposition'],
82
141
  RFC: ['decomposition'],
83
142
  Bug: ['bug'],
@@ -154,6 +213,8 @@ export function resolveForcedStep(step, { type }) {
154
213
  * @param {'open'|'closed'} [params.state]
155
214
  * @param {Array<string|{name:string}>} [params.labels]
156
215
  * @param {Record<string, DocState>} [params.docs] estado de spec/plan/decomposition/bug
216
+ * @param {Record<string, object>} [params.docPrs] PR aberto de cada documento em
217
+ * `pending-pr` — só para a mensagem
157
218
  * @param {Record<string, string>} [params.docPaths] caminhos relativos (só para as mensagens)
158
219
  * @param {StepAction|null} [params.forcedStep] resultado de resolveForcedStep
159
220
  * @param {boolean} [params.confirmed] `--yes`/`--apply` já dados
@@ -163,7 +224,7 @@ export function resolveForcedStep(step, { type }) {
163
224
  * blocked: null|{code: string, message: string, unblock: string}}}
164
225
  */
165
226
  export function nextStep({
166
- type, issueNumber, state = 'open', labels = [], docs = {}, docPaths = {},
227
+ type, issueNumber, state = 'open', labels = [], docs = {}, docPaths = {}, docPrs = {},
167
228
  forcedStep = null, confirmed = false, boardReady = true, force = false,
168
229
  } = {}) {
169
230
  const params = { issueNumber };
@@ -228,6 +289,25 @@ export function nextStep({
228
289
  }
229
290
  }
230
291
 
292
+ // G5.5 — documento gerado e esperando merge. Precisa vir ANTES do
293
+ // `pipelineStep`: para ele, um documento que não está na base "não existe", e
294
+ // a resposta seria REGERAR — pagando a chamada de IA outra vez, a cada
295
+ // execução, e descartando o que o revisor já editou dentro do PR.
296
+ //
297
+ // Antes do `forcedStep` também, de propósito: `--step spec --yes` é
298
+ // exatamente o comando que alguém impaciente rodaria aqui, e ele passaria por
299
+ // cima da revisão em curso.
300
+ for (const doc of docsOfType) {
301
+ const estado = docState(docs, doc);
302
+ if (isAwaitingMerge(estado)) {
303
+ const info = docPrs[doc] || {};
304
+ return decided('none', `\`${pathOf(doc)}\` foi gerado e ainda não está na base.`, params,
305
+ awaitingMergeBlock({
306
+ pathRel: pathOf(doc), state: estado, pr: info.pr || null, branch: info.branch || null,
307
+ }));
308
+ }
309
+ }
310
+
231
311
  const action = forcedStep || pipelineStep({ type, docs, has });
232
312
  if (action === 'none') {
233
313
  return decided('none', nothingPendingReason({ type, has }), params);
@@ -242,9 +322,9 @@ export function nextStep({
242
322
  `Rode \`${stepCommand(action, params)}\` quando decidir.`));
243
323
  }
244
324
 
245
- // G6 — documento que o passo lê ou escreve existe só no remoto. Gerar por cima
246
- // levaria o `pull --rebase` do commit a brigar com o documento bom.
247
- for (const doc of [step.writes, ...step.reads].filter(Boolean)) {
325
+ // G6 — documento que o passo lê ou escreve existe só na branch base. Gerar por
326
+ // cima publicaria um Pull Request que desfaz o documento bom.
327
+ for (const doc of stepDocs(action, type)) {
248
328
  if (docState(docs, doc) === 'remote') {
249
329
  return decided(action, `\`${pathOf(doc)}\` existe no repositório mas não no seu clone.`, params,
250
330
  block('stale-checkout',
@@ -285,8 +365,7 @@ export function nextStep({
285
365
  'Confirme com `--yes` se é isso mesmo.'));
286
366
  }
287
367
 
288
- const incertos = [step.writes, ...step.reads]
289
- .filter(Boolean)
368
+ const incertos = stepDocs(action, type)
290
369
  .filter(doc => docState(docs, doc) === 'unknown');
291
370
  const aviso = incertos.length > 0
292
371
  ? ` (não foi possível consultar o remoto por ${incertos.map(pathOf).join(', ')} — ` +
@@ -251,6 +251,16 @@ export function explainGitWriteError(err) {
251
251
  case 401:
252
252
  return `${msg} — token inválido ou expirado.`;
253
253
  case 403:
254
+ // O GitHub Actions recusa a criação de PR com uma mensagem própria, e ela
255
+ // é a falha MAIS provável deste fluxo: a opção "Allow GitHub Actions to
256
+ // create and approve pull requests" vem desligada em muitas organizações.
257
+ // Sem este ramo, o usuário lê "permissão de escrita" e vai mexer em
258
+ // escopo de token, que não é o problema.
259
+ if (/not permitted to create or approve pull requests/i.test(msg)) {
260
+ return `${msg} — o GITHUB_TOKEN está proibido de abrir Pull Requests neste ` +
261
+ 'repositório. Ligue Settings → Actions → General → "Allow GitHub Actions to ' +
262
+ 'create and approve pull requests", ou forneça um PAT no secret `GH_PR_TOKEN`.';
263
+ }
254
264
  return `${msg} — o token não tem permissão de escrita. Precisa de "Contents: write" e ` +
255
265
  '"Pull requests: write" (fine-grained) ou do escopo `repo` (classic).';
256
266
  case 404:
@@ -26,7 +26,7 @@ export function blobUrl({ owner, repo, ref, pathRel }) {
26
26
  * Qual ref o link deve citar (função PURA).
27
27
  *
28
28
  * No Actions, `GITHUB_REF_NAME` é a branch do evento. Localmente, a branch
29
- * corrente é a que `commitGenerated` acabou de publicar. `main` é o último
29
+ * corrente é a que o usuário está usando. `main` é o último
30
30
  * recurso — o mesmo valor que estava hardcoded, então nunca piora.
31
31
  *
32
32
  * @param {object} params
@@ -71,9 +71,15 @@ export function currentGitBranch(cwd = process.cwd()) {
71
71
  * @param {'actions'|'local'} params.mode
72
72
  * @param {string|null} [params.root] raiz do repo (para ler a branch corrente)
73
73
  * @param {object|null} [params.config]
74
+ * @param {string|null} [params.ref] ref EXPLÍCITA, com precedência sobre tudo.
75
+ * Com a publicação por Pull Request, quem gerou o documento sabe exatamente em
76
+ * que branch ele está — e a inferência abaixo erraria: num evento
77
+ * `issues: labeled` o `GITHUB_REF_NAME` é a branch DEFAULT, onde o documento
78
+ * ainda não existe, então todo link nasceria 404 até o merge.
74
79
  * @returns {string}
75
80
  */
76
- export function docBlobUrl({ owner, repo, pathRel, mode, root = null, config = null }) {
81
+ export function docBlobUrl({ owner, repo, pathRel, mode, root = null, config = null, ref: refExplicita = null }) {
82
+ if (refExplicita) return blobUrl({ owner, repo, ref: refExplicita, pathRel });
77
83
  const ref = resolveDocRef({
78
84
  mode,
79
85
  env: process.env,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "spec-wave",
3
3
  "displayName": "Spec Wave",
4
- "version": "0.25.0",
4
+ "version": "0.27.0",
5
5
  "description": "Fluxo spec-driven no GitHub (RFC-001): Projects v2, labels de gatilho, spec/plan gerados por Action, decomposição em duas etapas e implementação orientada a Stories/Tasks.",
6
6
  "author": {
7
7
  "name": "Astratech",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: spec-wave-bug
3
- description: "Use para gerar o bug.md de um defeito do spec-wave — reprodução, causa raiz, escopo do fix e teste de regressão. Aplica a label spec-wave:bug e deixa o GitHub Action gerar e commitar o arquivo em docs/bugs/<slug>/. Gatilhos: 'gerar o bug.md da issue 42', 'documentar a causa raiz do bug', 'rodar o spec-wave:bug'. Só vale para issues do tipo Bug — Feature usa spec/plan."
3
+ description: "Use para gerar o bug.md de um defeito do spec-wave — reprodução, causa raiz, escopo do fix e teste de regressão. Aplica a label spec-wave:bug e deixa o GitHub Action gerar o arquivo em docs/bugs/<slug>/ e abrir um Pull Request. Gatilhos: 'gerar o bug.md da issue 42', 'documentar a causa raiz do bug', 'rodar o spec-wave:bug'. Só vale para issues do tipo Bug — Feature usa spec/plan."
4
4
  allowed-tools:
5
5
  - Bash(gh issue *)
6
6
  - Bash(npx @spec-wave/cli@latest *)
@@ -9,7 +9,7 @@ allowed-tools:
9
9
 
10
10
  # spec-wave bug — o documento do defeito
11
11
 
12
- > **Regra fundamental: nunca escreva o `bug.md` você mesmo.** Aplique a label e deixe o Action gerar — é isso que garante que o arquivo seja commitado e referenciado na issue. Exceção: revisar/melhorar um `bug.md` já gerado (aí sim use Edit no arquivo local).
12
+ > **Regra fundamental: nunca escreva o `bug.md` você mesmo.** Aplique a label e deixe o Action gerar — é isso que garante que o arquivo chegue à main por Pull Request e seja referenciado na issue. Exceção: revisar/melhorar um `bug.md` já gerado (aí sim use Edit no arquivo local).
13
13
 
14
14
  **Contexto:** leia `.spec-wave.json` (Read). Ausente → skill **setup**.
15
15
 
@@ -24,7 +24,7 @@ Para qualquer outro tipo (Spike, Bug, Story, Task…) o Action **recusa** e come
24
24
 
25
25
  ```
26
26
  spec-wave:decompose
27
- ├─ decomposition.md ausente → gera via IA → commita → critica
27
+ ├─ decomposition.md ausente → gera via IA → abre Pull Request → critica
28
28
  └─ decomposition.md presente → critica o arquivo COMO ESTÁ (preserva suas edições)
29
29
  ├─ grave → +critique-failed, comentário citando Story N / Task N.M, exit 1
30
30
  └─ limpo → +decompose-ready, comentário "revise e aplique"
@@ -45,14 +45,14 @@ spec-wave:decompose-apply
45
45
  # ou Action — assíncrono
46
46
  gh issue edit <número> --add-label "spec-wave:decompose"
47
47
  ```
48
- Informe: "Rascunho iniciado — vai commitar o `decomposition.md` e passar pela crítica. **Nenhuma issue é criada nesta etapa.**"
48
+ Informe: "Rascunho iniciado — vai abrir um Pull Request com o `decomposition.md` e passar pela crítica. **Nenhuma issue é criada nesta etapa.**"
49
49
 
50
50
  3. **Leia o resultado** quando o Action terminar:
51
51
 
52
52
  | Label resultante | O que fazer |
53
53
  |------------------|-------------|
54
54
  | `spec-wave:decompose-ready` | Passou na crítica. **Leia o `decomposition.md`** e mostre ao usuário o que será criado (Stories, Tasks, dependências). Ofereça editar antes de aplicar. |
55
- | `spec-wave:critique-failed` | O Action **falhou (exit 1)** e nada foi criado. Os achados citam `Story N` / `Task N.M`. **Corrija o `decomposition.md`** — não o `plan.md` — commite e reaplique `spec-wave:decompose` (o arquivo é criticado como está, sem ser regerado). |
55
+ | `spec-wave:critique-failed` | O Action **falhou (exit 1)** e nada foi criado. Os achados citam `Story N` / `Task N.M`. **Corrija o `decomposition.md`** — não o `plan.md` — **no Pull Request** e reaplique `spec-wave:decompose` (o arquivo é criticado como está, sem ser regerado, venha ele do PR ou da base). |
56
56
  | `spec-wave:needs-human` | A crítica esgotou as tentativas. **Pare** e envolva o usuário: as duas labels precisam sair à mão. |
57
57
 
58
58
  4. **Etapa 2 — aplicar o rascunho aprovado**, só depois da revisão:
@@ -95,7 +95,7 @@ Corpo técnico.
95
95
  - a **posição** manda, não o número escrito — inserir uma Story no meio sem renumerar funciona
96
96
  - `**Depende de:**` aceita **irmãs** (`Story 1, Story 3`, 1-based, só para trás — apontar para si mesma ou para frente é **erro**, não filtro silencioso) e **issues de outras Features** (`#412`, que precisam JÁ existir); as duas formas convivem na mesma linha (`Story 1, #412`), e `—` significa nenhuma
97
97
  - o corpo aceita markdown livre (`## Backend`, cercas de código) — só `## Story N` e `### Task N.M` são estrutura
98
- - **para gerar outro rascunho do zero:** apague o arquivo e reaplique `spec-wave:decompose`
98
+ - **para gerar outro rascunho do zero:** feche o Pull Request e apague a branch `spec-wave/<n>-decompose` (ou apague o arquivo, se já foi mergeado) e reaplique `spec-wave:decompose`
99
99
 
100
100
  > ⚠️ O slug vem do **título**. Renomear a Feature entre o rascunho e o apply muda o diretório e órfã o `decomposition.md` — o apply reclama que não achou o rascunho.
101
101
 
@@ -14,7 +14,7 @@ allowed-tools:
14
14
 
15
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
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.
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, abrem um **Pull Request**, criticam e comentam na issue. Local exige a chave de IA no seu ambiente. Veja a skill **spec** para a tabela completa.
18
18
 
19
19
  **Contexto:** leia `.spec-wave.json` (Read). Ausente → skill **setup**.
20
20
 
@@ -26,7 +26,7 @@ npx @spec-wave/cli@latest mode actions # volta tudo para o CI
26
26
  npx @spec-wave/cli@latest run <issue> --dry-run
27
27
  ```
28
28
  2. Mostre ao usuário o passo, o motivo e o comando que rodaria. **Só então** execute sem a flag.
29
- 3. `--json` devolve a mesma decisão em JSON, quando você precisar ramificar programaticamente.
29
+ 3. `--json` devolve a decisão em JSON, quando você precisar ramificar programaticamente. É **um** documento: o desfecho no topo (`action`, `command`, `blocked`) e a sequência inteira em `steps` — útil com `--max-steps`.
30
30
 
31
31
  ## O que o `run` decide sozinho
32
32
 
@@ -50,13 +50,15 @@ O comando **explica e para**, saindo com código 2. Não force sem entender:
50
50
  - **`trigger-pending`** — há label de gatilho na issue: um Action está em voo (ou falhou deixando-a). Rodar por cima duplicaria documento e comentário. `--force` fura, quando você tem certeza.
51
51
  - **`needs-human` / `critique-failed`** — portões humanos da crítica. O caminho é corrigir o documento e remover a label; para o `critique-failed` o passo de retomada é **re-criticar**, nunca regerar.
52
52
  - **`stale-checkout`** — o documento existe no repositório e não no seu clone. `git pull` e repita; seguir sobrescreveria o que já foi publicado.
53
+ - **`pr-pending`** — o documento foi gerado e está num **Pull Request ainda não mergeado**. Revise o PR (edite ali mesmo, se precisar) e faça o merge; o passo seguinte lê o documento da branch base. Rodar por cima regeraria o arquivo, descartando a revisão e pagando a IA de novo.
54
+ - **`branch-without-pr`** — o documento foi commitado numa branch `spec-wave/*` e **nenhum PR foi aberto** para ela. Quase sempre a abertura do PR falhou: opção *"Allow GitHub Actions to create and approve pull requests"* desligada, ou secret `GH_PR_TOKEN` ausente. Rode `spec-wave doctor`, corrija e abra o PR — reaplicar o gatilho **regera** o documento.
53
55
  - **`needs-confirmation`** — o passo cria issues (`decompose-apply`) ou gasta IA repetindo a crítica de um rascunho existente. Revise antes e confirme com `--apply` / `--yes`.
54
56
  - **`inconsistent-state`** — as labels afirmam um documento que não existe. Quase sempre o título da issue mudou depois de gerar (o slug vira outro diretório).
55
57
 
56
58
  ## Regras que o `run` respeita — e você também
57
59
 
58
60
  - **Nunca aplique label de gatilho no modo local.** Ela dispararia o Action e o passo aconteceria duas vezes. O `run` não aplica nenhuma.
59
- - **Um passo por invocação.** `--max-steps <n>` encadeia, mas cada passo de IA custa dinheiro — encadeie só quando o usuário pedir.
61
+ - **Um passo por invocação.** `--max-steps <n>` encadeia, mas cada passo de IA custa dinheiro — encadeie só quando o usuário pedir. Na prática o encadeamento agora para sozinho: cada documento vai para um Pull Request, e o passo seguinte depende do merge.
60
62
  - A **Regra fundamental** continua valendo: nunca escreva `spec.md`/`plan.md`/`decomposition.md` à mão. Quem gera é a CLI, aqui como no Action.
61
63
 
62
64
  ## `mode` — o interruptor
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: spec-wave-spec
3
- description: "Use para iniciar a geração da especificação funcional (spec.md) de uma Feature do spec-wave — o PRIMEIRO documento do ciclo, antes do plano técnico. Aplica a label spec-wave:spec e deixa o GitHub Action gerar e commitar o arquivo. Gatilhos: 'gerar a spec da feature 12', 'criar especificação funcional', 'rodar o spec-wave:spec'. Só vale para Features — Spike, RFC e Bug não usam spec."
3
+ description: "Use para iniciar a geração da especificação funcional (spec.md) de uma Feature do spec-wave — o PRIMEIRO documento do ciclo, antes do plano técnico. Aplica a label spec-wave:spec e deixa o GitHub Action gerar o arquivo e abrir um Pull Request. Gatilhos: 'gerar a spec da feature 12', 'criar especificação funcional', 'rodar o spec-wave:spec'. Só vale para Features — Spike, RFC e Bug não usam spec."
4
4
  allowed-tools:
5
5
  - Bash(gh issue *)
6
6
  - Bash(npx @spec-wave/cli@latest *)
@@ -9,7 +9,7 @@ allowed-tools:
9
9
 
10
10
  # spec-wave spec — especificação funcional (1º documento)
11
11
 
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).
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 chegue à main por Pull Request e seja referenciado na issue. Exceção: revisar/melhorar um `spec.md` já gerado (aí sim use Edit).
13
13
 
14
14
  ## Dois modos, mesmo resultado
15
15
 
@@ -18,7 +18,7 @@ allowed-tools:
18
18
  | **Action** | aplicar a label `spec-wave:spec` | fluxo assíncrono; roda no CI, você acompanha pela issue |
19
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
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).
21
+ O modo é detectado pelo ambiente — fora do Actions, a CLI roda em modo local automaticamente. Os dois geram o arquivo, abrem um **Pull Request** com ele, 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
22
 
23
23
  > Local exige a credencial de IA no seu ambiente (`OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY` ou o login do Claude Code, se o provider for `claude-oauth`) e um `.spec-wave.json` no repositório. Para conduzir o fluxo inteiro sem gastar minutos de Actions, veja a skill **run**.
24
24
 
@@ -36,7 +36,7 @@ O modo é detectado pelo ambiente — fora do Actions, a CLI roda em modo local
36
36
  ```bash
37
37
  npx @spec-wave/cli@latest generate-spec --issue-number <número>
38
38
  ```
39
- O comando imprime `Modo de execução: local`, gera, commita e faz push.
39
+ O comando imprime `Modo de execução: local`, gera e abre o Pull Request. Nada é gravado no seu clone — o documento vive no PR até o merge.
40
40
 
41
41
  **Action** — assíncrono:
42
42
  ```bash
@@ -31,7 +31,7 @@ Toda a CLI é invocada como `npx @spec-wave/cli@latest <comando>`.
31
31
  - **Action:** aplique a label de gatilho (`spec-wave:spec`, `spec-wave:plan`, `spec-wave:decompose`);
32
32
  - **Local:** `npx @spec-wave/cli@latest generate-spec|generate-plan|decompose --issue-number <n>` na sua sessão.
33
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.
34
+ O modo é detectado pelo ambiente (`GITHUB_ACTIONS`), sem flag. Os dois geram, abrem um **Pull Request** com o documento, 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.
35
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).
36
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`.
37
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.
@@ -88,7 +88,7 @@ Depois do `generate-plan` e **antes** de qualquer issue nascer no `decompose`, u
88
88
  | Reprovou depois de | O que corrigir | Como retomar |
89
89
  |--------------------|----------------|--------------|
90
90
  | `generate-plan` | `plan.md` (ou a `spec.md` que o embasa) | corrija/regere → remova `critique-failed` → reaplique `spec-wave:ready` |
91
- | `decompose` | **`decomposition.md`** — os achados citam `Story N` / `Task N.M`, títulos **desse arquivo** | edite e commite → remova `critique-failed` → reaplique `spec-wave:decompose` (critica o arquivo como está, sem regerar) |
91
+ | `decompose` | **`decomposition.md`** — os achados citam `Story N` / `Task N.M`, títulos **desse arquivo** | edite no Pull Request → remova `critique-failed` → reaplique `spec-wave:decompose` (critica o arquivo como está, sem regerar) |
92
92
 
93
93
  > ⚠️ No `decompose` os achados são sobre as **Stories propostas**, não sobre o `plan.md`. Corrigir o plan não muda o rascunho já gravado.
94
94