@spec-wave/cli 0.24.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -0
- package/bin/spec-wave.mjs +4 -341
- package/package.json +1 -1
- package/src/api/github-rest.mjs +63 -1
- package/src/cli.mjs +400 -0
- package/src/commands/decompose.mjs +23 -16
- package/src/commands/doctor.mjs +32 -0
- package/src/commands/generate-bug.mjs +8 -14
- package/src/commands/generate-plan.mjs +2 -1
- package/src/commands/generate-spec.mjs +2 -1
- package/src/commands/mode.mjs +180 -0
- package/src/commands/run.mjs +491 -0
- package/src/commands/validate.mjs +22 -6
- package/src/config.mjs +4 -1
- package/src/lib/config-file.mjs +62 -0
- package/src/lib/doc-paths.mjs +51 -0
- package/src/lib/execution-mode.mjs +132 -0
- package/src/lib/next-step.mjs +412 -0
- package/src/lib/pr-step.mjs +102 -0
- package/src/lib/repo-links.mjs +84 -0
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/skills/doctor/SKILL.md +1 -0
- package/src/plugin/skills/run/SKILL.md +76 -0
- package/src/plugin/skills/spec/SKILL.md +1 -1
- package/src/templates/skill/SKILL.md +24 -1
- package/src/templates/workflows/code-review.yml +5 -1
- package/src/templates/workflows/critique.yml +4 -0
- package/src/templates/workflows/decompose.yml +4 -0
- package/src/templates/workflows/generate-bug.yml +4 -0
- package/src/templates/workflows/generate-plan.yml +4 -0
- package/src/templates/workflows/generate-spec.yml +4 -0
- package/src/templates/workflows/qa.yml +6 -1
- package/src/templates/workflows/validate.yml +4 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
// Qual é o próximo passo pendente de uma issue — a decisão que a LABEL tomava.
|
|
2
|
+
//
|
|
3
|
+
// No modo `actions` o gatilho é a label: o humano aplica `spec-wave:spec`, o
|
|
4
|
+
// workflow dispara. No modo `local` não há quem escute label, então alguém
|
|
5
|
+
// precisa olhar o estado da issue e dizer o que falta. É este módulo.
|
|
6
|
+
//
|
|
7
|
+
// PURO de propósito: sem fs, sem rede, sem env. Quem coleta as entradas é
|
|
8
|
+
// `commands/run.mjs`; aqui só entra dado já resolvido. É o que permite testar as
|
|
9
|
+
// três tabelas de decisão inteiras sem um único mock de API.
|
|
10
|
+
//
|
|
11
|
+
// A tabela STEPS é o antídoto da divergência entre os dois modos: cada ação
|
|
12
|
+
// declara a label que a dispararia no Actions, e um teste de paridade prova que
|
|
13
|
+
// toda label de gatilho tem exatamente uma ação. Sem essa amarra, um gatilho
|
|
14
|
+
// novo nasceria só de um lado e ninguém notaria.
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
LABEL_SPEC, LABEL_PLAN, LABEL_CRITIQUE, LABEL_DECOMPOSE, LABEL_DECOMPOSE_APPLY,
|
|
18
|
+
LABEL_DECOMPOSE_READY, LABEL_DECOMPOSED, LABEL_BUG, LABEL_BUG_APPROVED,
|
|
19
|
+
LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN, LABEL_PLAN_APPROVED, LABEL_TRIAGED,
|
|
20
|
+
LABEL_DUPLICATE, LABEL_WONT_FIX, LABEL_READY, labelNames,
|
|
21
|
+
} from '../config.mjs';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {'local'|'remote'|'missing'|'unknown'} DocState
|
|
25
|
+
* @typedef {'generate-spec'|'generate-plan'|'critique'|'validate'|'decompose'
|
|
26
|
+
* |'decompose-apply'|'generate-bug'|'triage'|'implement'|'none'} StepAction
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Ação → label que a dispararia no Actions, comando da CLI, documentos que ela
|
|
31
|
+
* lê e escreve, tipos aceitos e se ela CRIA issues (o que exige confirmação).
|
|
32
|
+
*/
|
|
33
|
+
export const STEPS = {
|
|
34
|
+
'generate-spec': {
|
|
35
|
+
trigger: LABEL_SPEC, cli: 'generate-spec', writes: 'spec', reads: [],
|
|
36
|
+
types: ['Feature'], creates: false, ai: true,
|
|
37
|
+
},
|
|
38
|
+
'generate-plan': {
|
|
39
|
+
trigger: LABEL_PLAN, cli: 'generate-plan', writes: 'plan', reads: ['spec'],
|
|
40
|
+
types: ['Feature'], creates: false, ai: true,
|
|
41
|
+
},
|
|
42
|
+
critique: {
|
|
43
|
+
trigger: LABEL_CRITIQUE, cli: 'critique', writes: null, reads: ['plan'],
|
|
44
|
+
types: ['Feature'], creates: false, ai: true,
|
|
45
|
+
},
|
|
46
|
+
validate: {
|
|
47
|
+
// `reads` DEPENDE DO TIPO: a validação de Feature abre spec.md e plan.md, a
|
|
48
|
+
// de Bug abre bug.md. Declarar `[]` aqui desarmava o G6 justamente para o
|
|
49
|
+
// passo que só lê do disco — um clone atrasado reprovava uma Feature cujo
|
|
50
|
+
// plan.md existia no remoto, removia `spec-wave:ready` e comentava a falha
|
|
51
|
+
// na issue. Dano a estado COMPARTILHADO por uma condição puramente local.
|
|
52
|
+
trigger: LABEL_READY, cli: 'validate', writes: null, reads: [],
|
|
53
|
+
readsByType: { Feature: ['spec', 'plan'], Bug: ['bug'] },
|
|
54
|
+
types: ['Feature', 'Bug'], creates: false, ai: false,
|
|
55
|
+
},
|
|
56
|
+
decompose: {
|
|
57
|
+
// O rascunho de Feature é montado a partir de spec.md + plan.md; o de RFC
|
|
58
|
+
// não usa nenhum dos dois. Com `[]` e o plan só no remoto, o decompose
|
|
59
|
+
// gerava o rascunho com o plano VAZIO — sem erro, só pior.
|
|
60
|
+
trigger: LABEL_DECOMPOSE, cli: 'decompose', writes: 'decomposition', reads: [],
|
|
61
|
+
readsByType: { Feature: ['spec', 'plan'], RFC: [] },
|
|
62
|
+
types: ['Feature', 'RFC'], creates: false, ai: true,
|
|
63
|
+
},
|
|
64
|
+
'decompose-apply': {
|
|
65
|
+
trigger: LABEL_DECOMPOSE_APPLY, cli: 'decompose --apply', writes: null,
|
|
66
|
+
reads: ['decomposition'], types: ['Feature', 'RFC'], creates: true, ai: false,
|
|
67
|
+
},
|
|
68
|
+
'generate-bug': {
|
|
69
|
+
trigger: LABEL_BUG, cli: 'generate-bug', writes: 'bug', reads: [],
|
|
70
|
+
types: ['Bug'], creates: false, ai: true,
|
|
71
|
+
},
|
|
72
|
+
// Sem gatilho por label: são decisões humanas, listadas aqui para o `run`
|
|
73
|
+
// saber dizer qual é o próximo movimento em vez de só "nada pendente".
|
|
74
|
+
triage: {
|
|
75
|
+
trigger: null, cli: 'triage accept', writes: null, reads: [],
|
|
76
|
+
types: ['Bug'], creates: false, ai: false, manual: true,
|
|
77
|
+
},
|
|
78
|
+
implement: {
|
|
79
|
+
trigger: null, cli: 'implement', writes: null, reads: [],
|
|
80
|
+
types: ['Feature', 'Bug', 'RFC'], creates: false, ai: true, manual: true,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Documentos que um passo LÊ, para um tipo de item (função PURA).
|
|
86
|
+
*
|
|
87
|
+
* Existe porque dois passos leem coisas diferentes conforme o tipo — `validate`
|
|
88
|
+
* abre spec+plan numa Feature e bug.md num Bug — e o G6 precisa da lista CERTA:
|
|
89
|
+
* ele é o que impede o passo de rodar contra um clone atrasado. Uma lista
|
|
90
|
+
* subdeclarada não causa erro visível, causa o passo rodando com o arquivo
|
|
91
|
+
* errado (ou ausente), que é o modo de falha caro.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} action nome da ação em STEPS
|
|
94
|
+
* @param {string|null} [type] tipo do work item
|
|
95
|
+
* @returns {string[]} documentos lidos
|
|
96
|
+
*/
|
|
97
|
+
export function stepReads(action, type) {
|
|
98
|
+
const step = STEPS[action];
|
|
99
|
+
if (!step) return [];
|
|
100
|
+
return step.readsByType?.[type] ?? step.reads;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Documentos que um passo TOCA (lê ou escreve), para um tipo (função PURA).
|
|
105
|
+
*
|
|
106
|
+
* É a lista que o G6, o aviso de "não deu para consultar o remoto" e a sonda do
|
|
107
|
+
* `run` precisam — os três erravam junto quando `reads` estava subdeclarado.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} action
|
|
110
|
+
* @param {string|null} [type]
|
|
111
|
+
* @returns {string[]}
|
|
112
|
+
*/
|
|
113
|
+
export function stepDocs(action, type) {
|
|
114
|
+
return [STEPS[action]?.writes, ...stepReads(action, type)].filter(Boolean);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Documentos que um tipo usa (função PURA).
|
|
119
|
+
*
|
|
120
|
+
* O `run` precisa disto para sondar o remoto quando a decisão veio BLOQUEADA e
|
|
121
|
+
* o passo é `none` — aí não há `writes`/`reads` de onde tirar a lista, e é
|
|
122
|
+
* justamente o caso em que o G5 afirma "não está no clone nem no remoto".
|
|
123
|
+
*
|
|
124
|
+
* @param {string|null} [type]
|
|
125
|
+
* @returns {string[]}
|
|
126
|
+
*/
|
|
127
|
+
export function docsForType(type) {
|
|
128
|
+
return DOCS_BY_TYPE[type] || [];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Tipos que o `run` sabe conduzir. Story/Task/Epic/Spike não têm passo de documento. */
|
|
132
|
+
export const RUNNABLE_TYPES = ['Feature', 'Bug', 'RFC'];
|
|
133
|
+
|
|
134
|
+
const TRIGGERS = Object.values(STEPS).map(s => s.trigger).filter(Boolean);
|
|
135
|
+
|
|
136
|
+
/** Documentos que cada tipo usa, na ordem em que o fluxo os produz. */
|
|
137
|
+
export const DOCS_BY_TYPE = {
|
|
138
|
+
Feature: ['spec', 'plan', 'decomposition'],
|
|
139
|
+
RFC: ['decomposition'],
|
|
140
|
+
Bug: ['bug'],
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const DEFAULT_DOC_PATHS = {
|
|
144
|
+
spec: 'spec.md', plan: 'plan.md', decomposition: 'decomposition.md', bug: 'bug.md',
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
function stepCommand(action, { issueNumber }) {
|
|
148
|
+
const step = STEPS[action];
|
|
149
|
+
if (!step || action === 'none') return null;
|
|
150
|
+
return step.manual
|
|
151
|
+
? `spec-wave ${step.cli} ${issueNumber}`
|
|
152
|
+
: `spec-wave ${step.cli} --issue-number ${issueNumber}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function docState(docs, name) {
|
|
156
|
+
return docs?.[name] || 'missing';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Para a POSIÇÃO no pipeline, o que conta é "o documento existe em algum lugar":
|
|
160
|
+
// `remote` conta (o passo seguinte é `git pull`, não regerar), `unknown` NÃO —
|
|
161
|
+
// não deu para consultar, e travar o fluxo por isso deixaria o usuário sem saída
|
|
162
|
+
// offline. Quem transforma `remote` em portão é o G6.
|
|
163
|
+
function docExists(docs, name) {
|
|
164
|
+
return ['local', 'remote'].includes(docState(docs, name));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function decided(action, reason, params, blocked = null) {
|
|
168
|
+
return { action, command: stepCommand(action, params), reason, blocked };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function block(code, message, unblock) {
|
|
172
|
+
return { code, message, unblock };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Passo forçado por `--step` (função PURA).
|
|
177
|
+
*
|
|
178
|
+
* @param {string} step nome da ação (aceita os apelidos curtos: spec, plan, bug, ready)
|
|
179
|
+
* @param {{type: string|null}} params
|
|
180
|
+
* @returns {{action: StepAction|null, error: string|null}}
|
|
181
|
+
*/
|
|
182
|
+
export function resolveForcedStep(step, { type }) {
|
|
183
|
+
const alias = {
|
|
184
|
+
spec: 'generate-spec', plan: 'generate-plan', bug: 'generate-bug',
|
|
185
|
+
ready: 'validate', apply: 'decompose-apply',
|
|
186
|
+
};
|
|
187
|
+
const action = alias[step] || step;
|
|
188
|
+
if (!STEPS[action] || STEPS[action].manual) {
|
|
189
|
+
const nomes = Object.entries(STEPS).filter(([, s]) => !s.manual).map(([a]) => a);
|
|
190
|
+
return { action: null, error: `Passo desconhecido: "${step}". Use um de: ${nomes.join(', ')}.` };
|
|
191
|
+
}
|
|
192
|
+
if (type && !STEPS[action].types.includes(type)) {
|
|
193
|
+
return {
|
|
194
|
+
action: null,
|
|
195
|
+
error: `O passo "${action}" não se aplica a ${type} (só a ${STEPS[action].types.join(', ')}).`,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
return { action, error: null };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* O próximo passo pendente do fluxo (função PURA).
|
|
203
|
+
*
|
|
204
|
+
* Quando `blocked` vem preenchido, `action` continua sendo o passo que RODARIA —
|
|
205
|
+
* é isso que permite dizer "o próximo passo é X, mas Y impede" em vez de um
|
|
206
|
+
* "não posso" mudo.
|
|
207
|
+
*
|
|
208
|
+
* @param {object} params
|
|
209
|
+
* @param {string|null} params.type tipo canônico da issue (detectIssueType)
|
|
210
|
+
* @param {number|string} params.issueNumber
|
|
211
|
+
* @param {'open'|'closed'} [params.state]
|
|
212
|
+
* @param {Array<string|{name:string}>} [params.labels]
|
|
213
|
+
* @param {Record<string, DocState>} [params.docs] estado de spec/plan/decomposition/bug
|
|
214
|
+
* @param {Record<string, string>} [params.docPaths] caminhos relativos (só para as mensagens)
|
|
215
|
+
* @param {StepAction|null} [params.forcedStep] resultado de resolveForcedStep
|
|
216
|
+
* @param {boolean} [params.confirmed] `--yes`/`--apply` já dados
|
|
217
|
+
* @param {boolean} [params.boardReady] o board está alcançável (gate do apply)
|
|
218
|
+
* @param {boolean} [params.force] `--force` fura o portão de gatilho pendente
|
|
219
|
+
* @returns {{action: StepAction, command: string|null, reason: string,
|
|
220
|
+
* blocked: null|{code: string, message: string, unblock: string}}}
|
|
221
|
+
*/
|
|
222
|
+
export function nextStep({
|
|
223
|
+
type, issueNumber, state = 'open', labels = [], docs = {}, docPaths = {},
|
|
224
|
+
forcedStep = null, confirmed = false, boardReady = true, force = false,
|
|
225
|
+
} = {}) {
|
|
226
|
+
const params = { issueNumber };
|
|
227
|
+
const names = labelNames(labels);
|
|
228
|
+
const has = label => names.includes(label);
|
|
229
|
+
const pathOf = doc => docPaths[doc] || DEFAULT_DOC_PATHS[doc] || doc;
|
|
230
|
+
|
|
231
|
+
// G0 — issue fechada: não há fluxo a conduzir.
|
|
232
|
+
if (state === 'closed') {
|
|
233
|
+
return decided('none', `A issue #${issueNumber} está fechada.`, params);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// G1 — tipo fora do fluxo de documentos.
|
|
237
|
+
if (!type || !RUNNABLE_TYPES.includes(type)) {
|
|
238
|
+
return decided('none', `Tipo ${type || 'desconhecido'} não tem passo automatizável.`, params,
|
|
239
|
+
block('unsupported-type',
|
|
240
|
+
`O fluxo de documentos cobre ${RUNNABLE_TYPES.join(', ')}. Story e Task nascem do decompose.`,
|
|
241
|
+
'Conduza este item pelo board (`spec-wave move`).'));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// G2 — portão humano duro: a crítica esgotou as tentativas.
|
|
245
|
+
if (has(LABEL_NEEDS_HUMAN)) {
|
|
246
|
+
return decided('none', 'A crítica reprovou repetidas vezes e parou o fluxo.', params,
|
|
247
|
+
block('needs-human',
|
|
248
|
+
`A label \`${LABEL_NEEDS_HUMAN}\` para o fluxo até uma pessoa revisar os documentos.`,
|
|
249
|
+
`Revise, corrija e remova \`${LABEL_NEEDS_HUMAN}\` (e \`${LABEL_CRITIQUE_FAILED}\`, se houver).`));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// G3 — crítica grave pendente. O caminho de retomada é corrigir o documento e
|
|
253
|
+
// RE-CRITICAR; regerar descartaria a correção (é o erro que a skill documenta).
|
|
254
|
+
if (has(LABEL_CRITIQUE_FAILED)) {
|
|
255
|
+
const action = type === 'Feature' ? 'critique' : 'none';
|
|
256
|
+
return decided(action, 'A crítica adversarial apontou contradições graves.', params,
|
|
257
|
+
block('critique-failed',
|
|
258
|
+
'Os achados estão no comentário 🔎 da issue e citam o documento por âncora.',
|
|
259
|
+
`Corrija o documento, remova \`${LABEL_CRITIQUE_FAILED}\` e rode de novo.`));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// G4 — gatilho aplicado: ou um Action está em voo, ou ele falhou e deixou a
|
|
263
|
+
// label. Rodar por cima gera documento e comentário em duplicata.
|
|
264
|
+
const pending = TRIGGERS.filter(has);
|
|
265
|
+
if (pending.length > 0 && !force) {
|
|
266
|
+
return decided('none', `Label de gatilho pendente na issue: ${pending.join(', ')}.`, params,
|
|
267
|
+
block('trigger-pending',
|
|
268
|
+
'A label significa Action em execução (ou que falhou deixando-a para trás).',
|
|
269
|
+
`Aguarde o run terminar, ou remova a label e repita — \`--force\` ignora este portão.`));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const docsOfType = DOCS_BY_TYPE[type] || [];
|
|
273
|
+
|
|
274
|
+
// G5 — as labels afirmam um documento que o clone não tem. Título editado
|
|
275
|
+
// muda o slug (e o diretório); clone velho não tem o arquivo.
|
|
276
|
+
const claims = [
|
|
277
|
+
[LABEL_PLAN_APPROVED, 'plan'], [LABEL_DECOMPOSED, 'decomposition'], [LABEL_BUG_APPROVED, 'bug'],
|
|
278
|
+
];
|
|
279
|
+
for (const [label, doc] of claims) {
|
|
280
|
+
if (has(label) && docsOfType.includes(doc) && docState(docs, doc) === 'missing') {
|
|
281
|
+
return decided('none', `A label \`${label}\` afirma um documento que não existe aqui.`, params,
|
|
282
|
+
block('inconsistent-state',
|
|
283
|
+
`\`${pathOf(doc)}\` não está no clone nem no remoto.`,
|
|
284
|
+
'O título da issue mudou depois de gerar (o slug vira outro diretório)? Renomeie o diretório ou regenere.'));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const action = forcedStep || pipelineStep({ type, docs, has });
|
|
289
|
+
if (action === 'none') {
|
|
290
|
+
return decided('none', nothingPendingReason({ type, has }), params);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const step = STEPS[action];
|
|
294
|
+
|
|
295
|
+
// Passo manual (triage, implement): existe passo, mas quem o dá é uma pessoa.
|
|
296
|
+
if (step.manual) {
|
|
297
|
+
return decided(action, manualReason(action), params,
|
|
298
|
+
block('manual', 'Este passo é decisão humana — o `run` não o executa.',
|
|
299
|
+
`Rode \`${stepCommand(action, params)}\` quando decidir.`));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// G6 — documento que o passo lê ou escreve existe só no remoto. Gerar por cima
|
|
303
|
+
// levaria o `pull --rebase` do commit a brigar com o documento bom.
|
|
304
|
+
for (const doc of stepDocs(action, type)) {
|
|
305
|
+
if (docState(docs, doc) === 'remote') {
|
|
306
|
+
return decided(action, `\`${pathOf(doc)}\` existe no repositório mas não no seu clone.`, params,
|
|
307
|
+
block('stale-checkout',
|
|
308
|
+
'Seguir agora ignoraria (ou sobrescreveria) o documento já publicado.',
|
|
309
|
+
'Rode `git pull` e repita.'));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// G7 — o passo cria issues: aplicar é a aprovação humana, nunca automática.
|
|
314
|
+
if (step.creates && !confirmed) {
|
|
315
|
+
return decided(action, applyReason({ type }), params,
|
|
316
|
+
block('needs-confirmation',
|
|
317
|
+
'Este passo CRIA issues a partir do rascunho revisado — reaplicar depois duplica.',
|
|
318
|
+
`Revise o rascunho e confirme com \`--apply\`.`));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// G8 — sem board não há onde posicionar o que for criado.
|
|
322
|
+
if (action === 'decompose-apply' && !boardReady) {
|
|
323
|
+
return decided(action, 'O board não está alcançável.', params,
|
|
324
|
+
block('board-unreachable',
|
|
325
|
+
'As issues nasceriam sem Etapa e sumiriam de todas as telas.',
|
|
326
|
+
'Confira o `project` no .spec-wave.json e o escopo `project` do token (GH_PROJECT_TOKEN).'));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Re-crítica do rascunho: existe arquivo, ninguém liberou, e rodar gasta IA.
|
|
330
|
+
if (action === 'decompose' && docExists(docs, 'decomposition') && !confirmed) {
|
|
331
|
+
return decided(action, 'O rascunho já existe e seria RE-CRITICADO (não regerado).', params,
|
|
332
|
+
block('needs-confirmation',
|
|
333
|
+
'Uma nova crítica custa uma chamada de IA; o rascunho em si é preservado.',
|
|
334
|
+
'Confirme com `--yes`, ou apague o arquivo para gerar outro do zero.'));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Passo forçado que sobrescreve documento existente.
|
|
338
|
+
if (forcedStep && step.writes && docState(docs, step.writes) === 'local' && !confirmed) {
|
|
339
|
+
return decided(action, `\`${pathOf(step.writes)}\` já existe e seria SOBRESCRITO.`, params,
|
|
340
|
+
block('needs-confirmation',
|
|
341
|
+
'Regerar descarta o que foi revisado à mão.',
|
|
342
|
+
'Confirme com `--yes` se é isso mesmo.'));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const incertos = stepDocs(action, type)
|
|
346
|
+
.filter(doc => docState(docs, doc) === 'unknown');
|
|
347
|
+
const aviso = incertos.length > 0
|
|
348
|
+
? ` (não foi possível consultar o remoto por ${incertos.map(pathOf).join(', ')} — ` +
|
|
349
|
+
'se já foi gerado, rode `git pull` antes)'
|
|
350
|
+
: '';
|
|
351
|
+
|
|
352
|
+
return decided(action, pipelineReason(action, { pathOf }) + aviso, params);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// A posição no pipeline, depois dos portões (função PURA interna).
|
|
356
|
+
function pipelineStep({ type, docs, has }) {
|
|
357
|
+
const existe = doc => docExists(docs, doc);
|
|
358
|
+
|
|
359
|
+
if (type === 'Feature') {
|
|
360
|
+
if (!existe('spec')) return 'generate-spec';
|
|
361
|
+
if (!existe('plan')) return 'generate-plan';
|
|
362
|
+
if (!has(LABEL_PLAN_APPROVED)) return 'validate';
|
|
363
|
+
if (has(LABEL_DECOMPOSED)) return 'implement';
|
|
364
|
+
if (has(LABEL_DECOMPOSE_READY)) return 'decompose-apply';
|
|
365
|
+
return 'decompose';
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (type === 'RFC') {
|
|
369
|
+
if (has(LABEL_DECOMPOSED)) return 'implement';
|
|
370
|
+
if (has(LABEL_DECOMPOSE_READY)) return 'decompose-apply';
|
|
371
|
+
return 'decompose';
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Bug: bug.md → validate → triagem (humana) → implement.
|
|
375
|
+
if (!existe('bug')) return 'generate-bug';
|
|
376
|
+
if (!has(LABEL_BUG_APPROVED)) return 'validate';
|
|
377
|
+
if (has(LABEL_DUPLICATE) || has(LABEL_WONT_FIX)) return 'none';
|
|
378
|
+
if (!has(LABEL_TRIAGED)) return 'triage';
|
|
379
|
+
return 'implement';
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function pipelineReason(action, { pathOf }) {
|
|
383
|
+
switch (action) {
|
|
384
|
+
case 'generate-spec': return `\`${pathOf('spec')}\` ainda não existe.`;
|
|
385
|
+
case 'generate-plan': return `A spec está pronta e \`${pathOf('plan')}\` ainda não existe.`;
|
|
386
|
+
case 'critique': return 'O plano precisa passar pela crítica adversarial de novo.';
|
|
387
|
+
case 'validate': return 'Os documentos existem e ainda não foram validados.';
|
|
388
|
+
case 'decompose': return `O rascunho \`${pathOf('decomposition')}\` ainda não existe.`;
|
|
389
|
+
case 'decompose-apply': return 'O rascunho foi aprovado pela crítica e espera revisão humana.';
|
|
390
|
+
case 'generate-bug': return `\`${pathOf('bug')}\` ainda não existe.`;
|
|
391
|
+
default: return 'Próximo passo do fluxo.';
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function applyReason({ type }) {
|
|
396
|
+
return type === 'RFC'
|
|
397
|
+
? 'O rascunho está pronto para virar Tasks.'
|
|
398
|
+
: 'O rascunho está pronto para virar Stories e Tasks.';
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function manualReason(action) {
|
|
402
|
+
return action === 'triage'
|
|
403
|
+
? 'O bug.md está validado — falta a triagem (aceitar, rejeitar ou marcar duplicado).'
|
|
404
|
+
: 'O fluxo de documentos terminou; o que falta é implementar.';
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function nothingPendingReason({ type, has }) {
|
|
408
|
+
if (type === 'Bug' && (has(LABEL_DUPLICATE) || has(LABEL_WONT_FIX))) {
|
|
409
|
+
return 'O bug foi triado para fora do fluxo (duplicado ou não será corrigido).';
|
|
410
|
+
}
|
|
411
|
+
return 'Nada pendente no fluxo de documentos.';
|
|
412
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// O que rodar para um Pull Request, quando não há evento do GitHub para escutar.
|
|
2
|
+
//
|
|
3
|
+
// No Actions o gatilho é o evento: `pull_request: [opened]` chama o
|
|
4
|
+
// `code-review`, `pull_request_review: [submitted]` com `state == 'approved'`
|
|
5
|
+
// chama o `qa`. Localmente o sinal equivalente é o ESTADO das reviews.
|
|
6
|
+
//
|
|
7
|
+
// PURO: sem rede. Quem busca PR e reviews é `commands/run.mjs`.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Veredito das reviews (função PURA).
|
|
11
|
+
*
|
|
12
|
+
* `approved` espelha exatamente o `if:` do qa.yml: basta existir uma review
|
|
13
|
+
* `APPROVED` — o Actions não checa se alguém pediu mudanças depois. Mas essa
|
|
14
|
+
* informação vai separada em `changesRequestedAfterApproval`, para o `run`
|
|
15
|
+
* exigir confirmação num caso em que o runner seguiria em frente calado.
|
|
16
|
+
*
|
|
17
|
+
* Uma review `DISMISSED` posterior derruba a aprovação anterior do MESMO autor —
|
|
18
|
+
* é o que "dismiss" significa na UI.
|
|
19
|
+
*
|
|
20
|
+
* @param {Array<{state?: string, user?: {login?: string}, author?: string, submitted_at?: string}>} reviews
|
|
21
|
+
* @returns {{approved: boolean, approvers: string[], changesRequestedAfterApproval: boolean}}
|
|
22
|
+
*/
|
|
23
|
+
export function reviewVerdict(reviews = []) {
|
|
24
|
+
const relevant = ['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'];
|
|
25
|
+
const porAutor = new Map();
|
|
26
|
+
|
|
27
|
+
for (const review of reviews) {
|
|
28
|
+
const state = String(review?.state || '').toUpperCase();
|
|
29
|
+
if (!relevant.includes(state)) continue; // COMMENTED/PENDING não são veredito
|
|
30
|
+
const autor = review?.user?.login || review?.author || '(desconhecido)';
|
|
31
|
+
porAutor.set(autor, state);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const approvers = [...porAutor].filter(([, state]) => state === 'APPROVED').map(([autor]) => autor);
|
|
35
|
+
const mudancasPedidas = [...porAutor.values()].includes('CHANGES_REQUESTED');
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
approved: approvers.length > 0,
|
|
39
|
+
approvers,
|
|
40
|
+
changesRequestedAfterApproval: approvers.length > 0 && mudancasPedidas,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Quais comandos rodar para este PR (função PURA).
|
|
46
|
+
*
|
|
47
|
+
* A ordem importa e é sempre a mesma: `code-review` antes de `qa`. Num repo em
|
|
48
|
+
* modo local o `code-review` nunca rodou na abertura do PR, e o `qa` sozinho
|
|
49
|
+
* mexe só na Feature/Bug — as Tasks ficariam fora de 🎉 Done e as Stories fora
|
|
50
|
+
* de 👀 Code Review. Os dois são board-only, sem IA e idempotentes
|
|
51
|
+
* (`advanceToStage` nunca retrocede), então encadeá-los não tem o custo que
|
|
52
|
+
* justifica o "um passo por vez" do modo issue.
|
|
53
|
+
*
|
|
54
|
+
* @param {object} params
|
|
55
|
+
* @param {'open'|'closed'} params.state
|
|
56
|
+
* @param {boolean} [params.draft]
|
|
57
|
+
* @param {boolean} [params.merged]
|
|
58
|
+
* @param {boolean} [params.approved]
|
|
59
|
+
* @param {boolean} [params.changesRequestedAfterApproval]
|
|
60
|
+
* @param {string|null} [params.only] 'code-review' | 'qa'
|
|
61
|
+
* @returns {{steps: string[], reason: string, warnings: string[],
|
|
62
|
+
* blocked: null|{code: string, message: string, unblock: string}}}
|
|
63
|
+
*/
|
|
64
|
+
export function nextPrStep({
|
|
65
|
+
state, draft = false, merged = false, approved = false,
|
|
66
|
+
changesRequestedAfterApproval = false, only = null,
|
|
67
|
+
} = {}) {
|
|
68
|
+
const warnings = [];
|
|
69
|
+
|
|
70
|
+
if (draft) {
|
|
71
|
+
return {
|
|
72
|
+
steps: [], warnings, reason: 'PR em rascunho.',
|
|
73
|
+
blocked: {
|
|
74
|
+
code: 'draft',
|
|
75
|
+
message: 'Nem o code-review.yml dispara para rascunho.',
|
|
76
|
+
unblock: 'Marque o PR como pronto para revisão.',
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (state === 'closed' && !merged) {
|
|
82
|
+
return { steps: [], warnings, reason: 'PR fechado sem merge.', blocked: null };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (merged) warnings.push('PR já mergeado — o board é atualizado como se o review tivesse acabado agora.');
|
|
86
|
+
if (merged && !approved) warnings.push('Mergeado sem review aprovada: o `qa` não roda (o qa.yml também não rodaria).');
|
|
87
|
+
if (changesRequestedAfterApproval) {
|
|
88
|
+
warnings.push('Há aprovação E pedido de mudanças: o Actions moveria assim mesmo — confirme com `--yes`.');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let steps = approved ? ['code-review', 'qa'] : ['code-review'];
|
|
92
|
+
if (only) steps = steps.filter(s => s === only);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
steps,
|
|
96
|
+
warnings,
|
|
97
|
+
reason: approved
|
|
98
|
+
? 'PR com review aprovada: board até 🧪 QA.'
|
|
99
|
+
: 'PR sem review aprovada: board até 👀 Code Review.',
|
|
100
|
+
blocked: null,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Links para os documentos gerados, apontando para a REF que de fato os recebeu.
|
|
2
|
+
//
|
|
3
|
+
// Os comentários das issues traziam `blob/main/...` fixo. No runner isso é quase
|
|
4
|
+
// sempre verdade (o push vai para a branch default), mas o modo local publica da
|
|
5
|
+
// branch corrente — e aí o link nasce 404 até o merge, ou para sempre. Com o
|
|
6
|
+
// `run` industrializando a execução local, o defeito deixaria de ser eventual.
|
|
7
|
+
//
|
|
8
|
+
// Decisão pura (`blobUrl`, `resolveDocRef`), I/O isolado em `currentGitBranch`.
|
|
9
|
+
|
|
10
|
+
import { execSync } from 'node:child_process';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* URL de um arquivo no GitHub (função PURA).
|
|
14
|
+
*
|
|
15
|
+
* A ref não é escapada: nome de branch com `/` (feat/x) funciona cru na URL do
|
|
16
|
+
* GitHub, e `encodeURIComponent` o quebraria.
|
|
17
|
+
*
|
|
18
|
+
* @param {{owner: string, repo: string, ref: string, pathRel: string}} params
|
|
19
|
+
* @returns {string}
|
|
20
|
+
*/
|
|
21
|
+
export function blobUrl({ owner, repo, ref, pathRel }) {
|
|
22
|
+
return `https://github.com/${owner}/${repo}/blob/${ref}/${pathRel}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Qual ref o link deve citar (função PURA).
|
|
27
|
+
*
|
|
28
|
+
* No Actions, `GITHUB_REF_NAME` é a branch do evento. Localmente, a branch
|
|
29
|
+
* corrente é a que `commitGenerated` acabou de publicar. `main` é o último
|
|
30
|
+
* recurso — o mesmo valor que estava hardcoded, então nunca piora.
|
|
31
|
+
*
|
|
32
|
+
* @param {object} params
|
|
33
|
+
* @param {'actions'|'local'} params.mode
|
|
34
|
+
* @param {object} [params.env]
|
|
35
|
+
* @param {string|null} [params.gitBranch] branch corrente (modo local)
|
|
36
|
+
* @param {object|null} [params.config] `.spec-wave.json` (campo `defaultBranch`)
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
export function resolveDocRef({ mode, env = {}, gitBranch = null, config = null }) {
|
|
40
|
+
const fallback = config?.defaultBranch || 'main';
|
|
41
|
+
if (mode === 'actions') return env.GITHUB_REF_NAME || fallback;
|
|
42
|
+
return gitBranch || fallback;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Branch corrente do clone. Best-effort — NUNCA lança.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} [cwd]
|
|
49
|
+
* @returns {string|null} null em detached HEAD ou fora de um repositório git
|
|
50
|
+
*/
|
|
51
|
+
export function currentGitBranch(cwd = process.cwd()) {
|
|
52
|
+
try {
|
|
53
|
+
const branch = execSync('git rev-parse --abbrev-ref HEAD', {
|
|
54
|
+
cwd,
|
|
55
|
+
encoding: 'utf-8',
|
|
56
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
57
|
+
}).trim();
|
|
58
|
+
return branch && branch !== 'HEAD' ? branch : null;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* URL do documento gerado, resolvendo a ref pelo modo de execução.
|
|
66
|
+
*
|
|
67
|
+
* @param {object} params
|
|
68
|
+
* @param {string} params.owner
|
|
69
|
+
* @param {string} params.repo
|
|
70
|
+
* @param {string} params.pathRel
|
|
71
|
+
* @param {'actions'|'local'} params.mode
|
|
72
|
+
* @param {string|null} [params.root] raiz do repo (para ler a branch corrente)
|
|
73
|
+
* @param {object|null} [params.config]
|
|
74
|
+
* @returns {string}
|
|
75
|
+
*/
|
|
76
|
+
export function docBlobUrl({ owner, repo, pathRel, mode, root = null, config = null }) {
|
|
77
|
+
const ref = resolveDocRef({
|
|
78
|
+
mode,
|
|
79
|
+
env: process.env,
|
|
80
|
+
gitBranch: mode === 'actions' ? null : currentGitBranch(root || process.cwd()),
|
|
81
|
+
config,
|
|
82
|
+
});
|
|
83
|
+
return blobUrl({ owner, repo, ref, pathRel });
|
|
84
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spec-wave",
|
|
3
3
|
"displayName": "Spec Wave",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.26.0",
|
|
5
5
|
"description": "Fluxo spec-driven no GitHub (RFC-001): Projects v2, labels de gatilho, spec/plan gerados por Action, decomposição em duas etapas e implementação orientada a Stories/Tasks.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Astratech",
|
|
@@ -44,6 +44,7 @@ npx @spec-wave/cli@latest doctor
|
|
|
44
44
|
- `.spec-wave.json` dessincronizado → `npx @spec-wave/cli@latest refresh --config`
|
|
45
45
|
- workflows/labels divergentes → skill **update**
|
|
46
46
|
- secret de IA ausente → Settings → Secrets → Actions (`ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN` ou `OPENROUTER_API_KEY`, conforme o provider)
|
|
47
|
+
- modo de execução divergente (config diz `local` e a variável `SPEC_WAVE_EXECUTION` não está setada, ou vice-versa) → `npx @spec-wave/cli@latest mode local|actions` — veja a skill **run**
|
|
47
48
|
- `specKit.command` ausente → configure antes de usar a skill **implement**
|
|
48
49
|
3. Trate `!` como "não deu para verificar", não como falha.
|
|
49
50
|
4. Se tudo passar e o problema original persistir, aí sim investigue a superfície específica (Action, issue, board).
|