@spec-wave/cli 0.11.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/bin/spec-wave.mjs +3 -0
- package/package.json +1 -1
- package/src/api/github-graphql.mjs +4 -0
- package/src/api/github-rest.mjs +13 -0
- package/src/commands/decompose.mjs +183 -19
- package/src/commands/generate-plan.mjs +7 -1
- package/src/commands/generate-spec.mjs +9 -1
- package/src/config.mjs +5 -0
- package/src/lib/force.mjs +34 -0
- package/src/templates/skill/SKILL.md +17 -4
package/README.md
CHANGED
|
@@ -85,6 +85,14 @@ 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` não muda nada: essas etapas já sobrescrevem o documento a cada acionamento. A flag equivalente para execução local é `--force`.
|
|
95
|
+
|
|
88
96
|
### Skill (`src/templates/skill/SKILL.md`)
|
|
89
97
|
|
|
90
98
|
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
|
@@ -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
|
}
|
package/src/api/github-rest.mjs
CHANGED
|
@@ -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,9 @@
|
|
|
1
1
|
import { existsSync, 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';
|
|
6
7
|
import { generateDocument } from '../lib/claude.mjs';
|
|
7
8
|
import { runCritique } from '../lib/critique.mjs';
|
|
8
9
|
import { recordUsage } from '../lib/usage-report.mjs';
|
|
@@ -10,7 +11,10 @@ import { formatDependencyLine } from '../lib/dependencies.mjs';
|
|
|
10
11
|
import { lintLanguage } from '../lib/output-lint.mjs';
|
|
11
12
|
import { slugify } from '../lib/slugify.mjs';
|
|
12
13
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
13
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
DECOMPOSE_TARGETS, LABEL_DECOMPOSED, LABEL_CRITIQUE_FAILED, LABEL_FORCE,
|
|
16
|
+
TARGET_LANGUAGE, STAGE_READY, STAGE_DEVELOPMENT, STAGE_ORDER, PROGRESS_TODO,
|
|
17
|
+
} from '../config.mjs';
|
|
14
18
|
|
|
15
19
|
// Adiciona a issue ao board na Etapa ✅ Ready / Status Todo. Best-effort; a
|
|
16
20
|
// Etapa nunca retrocede (advanceToStage não toca itens já adiante).
|
|
@@ -19,15 +23,61 @@ async function moveToReady(token, project, etapaField, statusField, nodeId) {
|
|
|
19
23
|
await advanceToStage(token, project, etapaField, statusField, nodeId, STAGE_READY, PROGRESS_TODO);
|
|
20
24
|
}
|
|
21
25
|
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
// Dobra as barras invertidas que NÃO iniciam um escape válido de JSON. O corpo
|
|
27
|
+
// das tasks costuma trazer trecho de shell/YAML/regex ("\d+", "gradlew \" no fim
|
|
28
|
+
// da linha) e o modelo emite a barra crua: `JSON.parse` morre com "Bad escaped
|
|
29
|
+
// character". Escapes válidos são consumidos inteiros pela regex, então `\\`,
|
|
30
|
+
// `\n` e `\uXXXX` passam intactos.
|
|
31
|
+
function repairEscapes(text) {
|
|
32
|
+
return text.replace(/\\(u[0-9a-fA-F]{4}|["\\/bfnrt])?/g, (match, valid) => (valid ? match : '\\\\'));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Trecho ao redor da posição que o V8 reporta ("... at position 18321"), para o
|
|
36
|
+
// log da Action mostrar O QUE quebrou em vez de só um offset.
|
|
37
|
+
function excerptAt(text, message) {
|
|
38
|
+
const at = /position (\d+)/.exec(message || '');
|
|
39
|
+
if (!at) return '';
|
|
40
|
+
const pos = Number(at[1]);
|
|
41
|
+
const excerpt = text.slice(Math.max(0, pos - 60), pos + 60).replace(/\s+/g, ' ').trim();
|
|
42
|
+
return excerpt ? ` Trecho: …${excerpt}…` : '';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Extrai o JSON da resposta do modelo (função PURA — testável).
|
|
47
|
+
*
|
|
48
|
+
* Tenta, em ordem: conteúdo de fence de código, resposta inteira e o primeiro
|
|
49
|
+
* objeto `{...}` do texto; cada candidato é parseado cru e, se falhar, com os
|
|
50
|
+
* escapes reparados. Sem candidato válido, lança com o motivo e o trecho.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} raw resposta bruta do modelo
|
|
53
|
+
* @returns {object} objeto decodificado
|
|
54
|
+
*/
|
|
55
|
+
export function parseModelJson(raw) {
|
|
56
|
+
const text = String(raw ?? '');
|
|
57
|
+
const candidates = [];
|
|
58
|
+
const fence = text.match(/```(?:json)?\s*\n?([\s\S]*?)```/);
|
|
59
|
+
if (fence) candidates.push(fence[1]);
|
|
60
|
+
candidates.push(text);
|
|
61
|
+
const obj = text.match(/\{[\s\S]*\}/);
|
|
62
|
+
if (obj) candidates.push(obj[0]);
|
|
63
|
+
|
|
64
|
+
let failure = null;
|
|
65
|
+
for (const candidate of candidates) {
|
|
66
|
+
const trimmed = candidate.trim();
|
|
67
|
+
if (!trimmed) continue;
|
|
68
|
+
for (const attempt of [trimmed, repairEscapes(trimmed)]) {
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(attempt);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
failure = { message: err.message, text: attempt };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
30
75
|
}
|
|
76
|
+
|
|
77
|
+
throw new Error(
|
|
78
|
+
`O modelo não devolveu JSON válido: ${failure?.message ?? 'nenhum objeto encontrado na resposta'}.` +
|
|
79
|
+
(failure ? excerptAt(failure.text, failure.message) : '')
|
|
80
|
+
);
|
|
31
81
|
}
|
|
32
82
|
|
|
33
83
|
// Prefixo de título das sub-issues geradas por cada tipo decompoível.
|
|
@@ -40,13 +90,19 @@ const CHILD_PREFIX = { Feature: '[STORY]', RFC: '[TASK]' };
|
|
|
40
90
|
* sub-issues já contêm um item do tipo-alvo (Feature → algum `[STORY]` no
|
|
41
91
|
* título; RFC → algum `[TASK]`). Sub-issues de outro tipo não contam.
|
|
42
92
|
*
|
|
93
|
+
* Com `force` (flag `--force` ou label `spec-wave:force`) nada é pulado — o
|
|
94
|
+
* chamador é quem limpa a decomposição anterior antes de gerar a nova.
|
|
95
|
+
*
|
|
43
96
|
* @param {object} params
|
|
44
97
|
* @param {Array<string|{name: string}>} [params.labels] labels da issue
|
|
45
98
|
* @param {Array<{ number?: number, title?: string }>} [params.subIssues] sub-issues existentes
|
|
46
99
|
* @param {string} params.type tipo da issue ('Feature' | 'RFC')
|
|
100
|
+
* @param {boolean} [params.force] re-executa ignorando os guards
|
|
47
101
|
* @returns {{ skip: boolean, reason: string }}
|
|
48
102
|
*/
|
|
49
|
-
export function shouldSkipDecompose({ labels = [], subIssues = [], type } = {}) {
|
|
103
|
+
export function shouldSkipDecompose({ labels = [], subIssues = [], type, force = false } = {}) {
|
|
104
|
+
if (force) return { skip: false, reason: '' };
|
|
105
|
+
|
|
50
106
|
const names = labels
|
|
51
107
|
.map(l => (typeof l === 'string' ? l : l?.name))
|
|
52
108
|
.filter(Boolean);
|
|
@@ -66,6 +122,103 @@ export function shouldSkipDecompose({ labels = [], subIssues = [], type } = {})
|
|
|
66
122
|
return { skip: false, reason: '' };
|
|
67
123
|
}
|
|
68
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Planeja a limpeza de um re-decompose forçado (função PURA — testável).
|
|
127
|
+
*
|
|
128
|
+
* Só entram as sub-issues do tipo-alvo (Feature → `[STORY]`, RFC → `[TASK]`)
|
|
129
|
+
* que ainda estão abertas — as já fechadas não precisam de nada. Separa as que
|
|
130
|
+
* já saíram de ✅ Ready (Etapa ≥ 🚧 Desenvolvimento): fechá-las descarta
|
|
131
|
+
* trabalho em andamento, então elas são destacadas no aviso.
|
|
132
|
+
*
|
|
133
|
+
* @param {object} params
|
|
134
|
+
* @param {Array<{number:number, title?:string, nodeId?:string, state?:string}>} [params.subIssues]
|
|
135
|
+
* @param {string} params.type tipo da issue pai ('Feature' | 'RFC')
|
|
136
|
+
* @param {Record<number, string|null>} [params.stages] Etapa atual por número de issue
|
|
137
|
+
* @returns {{ close: object[], started: object[] }} close: a fechar (com `stage`);
|
|
138
|
+
* started: subconjunto de close que já passou de Ready.
|
|
139
|
+
*/
|
|
140
|
+
export function planForcedCleanup({ subIssues = [], type, stages = {} } = {}) {
|
|
141
|
+
const prefix = CHILD_PREFIX[type];
|
|
142
|
+
if (!prefix) return { close: [], started: [] };
|
|
143
|
+
|
|
144
|
+
const devIdx = STAGE_ORDER.indexOf(STAGE_DEVELOPMENT);
|
|
145
|
+
const close = subIssues
|
|
146
|
+
.filter(s => (s.title || '').includes(prefix) && s.state !== 'closed')
|
|
147
|
+
.map(s => ({ ...s, stage: stages[s.number] ?? null }));
|
|
148
|
+
const started = close.filter(s => {
|
|
149
|
+
const idx = s.stage ? STAGE_ORDER.indexOf(s.stage) : -1;
|
|
150
|
+
return idx !== -1 && devIdx !== -1 && idx >= devIdx;
|
|
151
|
+
});
|
|
152
|
+
return { close, started };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Fecha a decomposição anterior (sub-issues do tipo-alvo e os filhos delas) e
|
|
156
|
+
// comenta o que foi fechado. Best-effort item a item: uma falha vira warn e o
|
|
157
|
+
// re-decompose segue — melhor uma issue órfã do que abortar no meio.
|
|
158
|
+
async function closeStaleSubIssues(ctx, subIssues) {
|
|
159
|
+
const { token, projectToken, owner, repo, issueNumber, project, etapaField, type } = ctx;
|
|
160
|
+
|
|
161
|
+
// Etapa de cada sub-issue (best-effort) — só para avisar o que já saiu de Ready.
|
|
162
|
+
const stages = {};
|
|
163
|
+
if (project?.id && etapaField?.id) {
|
|
164
|
+
await Promise.all(subIssues.map(async (s) => {
|
|
165
|
+
if (!s.nodeId) return;
|
|
166
|
+
try {
|
|
167
|
+
const itemId = await addProjectItem(projectToken, project.id, s.nodeId);
|
|
168
|
+
stages[s.number] = await getItemSingleSelectValue(projectToken, itemId, etapaField.id);
|
|
169
|
+
} catch {
|
|
170
|
+
stages[s.number] = null;
|
|
171
|
+
}
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const { close, started } = planForcedCleanup({ subIssues, type, stages });
|
|
176
|
+
if (close.length === 0) {
|
|
177
|
+
console.log('Re-decompose forçado: nenhuma sub-issue anterior aberta para fechar.');
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
console.log(`Re-decompose forçado: fechando ${close.length} sub-issue(s) anterior(es)...`);
|
|
182
|
+
const closed = [];
|
|
183
|
+
for (const s of close) {
|
|
184
|
+
// Filhos primeiro (Tasks de uma Story): fechar só a Story deixaria as Tasks
|
|
185
|
+
// órfãs e abertas no board.
|
|
186
|
+
const children = s.nodeId ? await listSubIssues(token, s.nodeId).catch(() => []) : [];
|
|
187
|
+
for (const child of children.filter(c => c.state !== 'closed')) {
|
|
188
|
+
try {
|
|
189
|
+
await closeIssue(token, owner, repo, child.number);
|
|
190
|
+
} catch (err) {
|
|
191
|
+
console.warn(` Falha ao fechar #${child.number}: ${err.message}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
await closeIssue(token, owner, repo, s.number);
|
|
196
|
+
closed.push({ ...s, children: children.length });
|
|
197
|
+
console.log(` #${s.number} fechada${children.length ? ` (+${children.length} filha(s))` : ''}.`);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
console.warn(` Falha ao fechar #${s.number}: ${err.message}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (closed.length === 0) return;
|
|
204
|
+
const lines = closed.map(s =>
|
|
205
|
+
`- #${s.number} ${s.title}${s.stage ? ` — Etapa ${s.stage}` : ''}` +
|
|
206
|
+
(s.children ? ` _(+${s.children} sub-issue(s))_` : '')
|
|
207
|
+
);
|
|
208
|
+
const startedWarning = started.length > 0
|
|
209
|
+
? `\n\n⚠️ ${started.length} delas já tinha(m) saído de **${STAGE_READY}**: ` +
|
|
210
|
+
`${started.map(s => `#${s.number} (${s.stage})`).join(', ')} — ` +
|
|
211
|
+
'confira se algum trabalho em andamento foi descartado.'
|
|
212
|
+
: '';
|
|
213
|
+
await commentOnIssue(
|
|
214
|
+
token, owner, repo, parseInt(issueNumber, 10),
|
|
215
|
+
`🔀 **Re-decompose forçado** (label \`${LABEL_FORCE}\`)\n\n` +
|
|
216
|
+
`Fechadas ${closed.length} sub-issue(s) da decomposição anterior:\n\n${lines.join('\n')}` +
|
|
217
|
+
startedWarning +
|
|
218
|
+
'\n\nGerando a nova decomposição…'
|
|
219
|
+
).catch(() => {});
|
|
220
|
+
}
|
|
221
|
+
|
|
69
222
|
// Lint de idioma sobre títulos+corpos gerados; retorna aviso pronto para
|
|
70
223
|
// anexar ao comentário final ('' se limpo).
|
|
71
224
|
function formatItemsLintWarning(texts) {
|
|
@@ -147,7 +300,7 @@ async function decomposeFeature(ctx) {
|
|
|
147
300
|
`\n## plan.md\n${planContent}`,
|
|
148
301
|
].join('\n');
|
|
149
302
|
|
|
150
|
-
const decomposition =
|
|
303
|
+
const decomposition = parseModelJson(await generateDocument(FEATURE_SYSTEM_PROMPT, userContent, { action: 'decompose', usage }));
|
|
151
304
|
|
|
152
305
|
// Crítica adversarial ANTES de criar qualquer issue: stories que contradizem
|
|
153
306
|
// a spec/plan não devem virar trabalho. Crítica indisponível → só avisa.
|
|
@@ -283,7 +436,7 @@ async function decomposeRFC(ctx) {
|
|
|
283
436
|
`\n## Descrição\n${issue.body || '(sem descrição)'}`,
|
|
284
437
|
].join('\n');
|
|
285
438
|
|
|
286
|
-
const decomposition =
|
|
439
|
+
const decomposition = parseModelJson(await generateDocument(RFC_SYSTEM_PROMPT, userContent, { action: 'decompose', usage }));
|
|
287
440
|
const rfcNodeId = issue.node_id;
|
|
288
441
|
const tasks = decomposition.tasks || [];
|
|
289
442
|
const created = [];
|
|
@@ -327,7 +480,7 @@ async function decomposeRFC(ctx) {
|
|
|
327
480
|
console.log(`Decomposição concluída: ${created.length} tasks criadas.`);
|
|
328
481
|
}
|
|
329
482
|
|
|
330
|
-
export async function decompose({ issueNumber }) {
|
|
483
|
+
export async function decompose({ issueNumber, force = false }) {
|
|
331
484
|
const token = await resolveToken();
|
|
332
485
|
// PROJECT_TOKEN deve ter scope "project" para atualizar GitHub Projects v2.
|
|
333
486
|
// Fallback para GITHUB_TOKEN (só funciona em repos pessoais sem org restrictions).
|
|
@@ -366,17 +519,25 @@ export async function decompose({ issueNumber }) {
|
|
|
366
519
|
console.warn(`Não foi possível listar sub-issues: ${err.message} — seguindo sem o guard de sub-issues.`);
|
|
367
520
|
subIssues = [];
|
|
368
521
|
}
|
|
369
|
-
|
|
522
|
+
// Force (flag --force ou label spec-wave:force): ignora o guard e limpa a
|
|
523
|
+
// decomposição anterior antes de gerar a nova.
|
|
524
|
+
const forced = isForced({ labels: issue.labels || [], flag: force });
|
|
525
|
+
const guard = shouldSkipDecompose({ labels: issue.labels || [], subIssues, type, force: forced });
|
|
370
526
|
if (guard.skip) {
|
|
371
527
|
console.log(`Decompose ignorado: ${guard.reason}.`);
|
|
372
528
|
await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
|
|
373
529
|
await commentOnIssue(
|
|
374
530
|
token, owner, repo, parseInt(issueNumber, 10),
|
|
375
|
-
`⏭️ **decompose ignorado:** ${guard.reason}
|
|
376
|
-
|
|
531
|
+
`⏭️ **decompose ignorado:** ${guard.reason}.\n\n` +
|
|
532
|
+
`Para re-decompor, adicione a label \`${LABEL_FORCE}\` junto com \`spec-wave:decompose\` — ` +
|
|
533
|
+
'as sub-issues da decomposição anterior serão **fechadas** e novas serão geradas.'
|
|
377
534
|
).catch(() => {});
|
|
378
535
|
return;
|
|
379
536
|
}
|
|
537
|
+
// Consome a label assim que ela é lida — se o run for cancelado/morto mais
|
|
538
|
+
// adiante, ela não fica pendurada forçando silenciosamente os runs seguintes.
|
|
539
|
+
await consumeForceLabel(token, owner, repo, parseInt(issueNumber, 10));
|
|
540
|
+
if (forced) console.log(`Modo forçado ativo (${force ? 'flag --force' : `label ${LABEL_FORCE}`}).`);
|
|
380
541
|
|
|
381
542
|
// Projeto + campos Etapa/Status do board (reutilizados em todos os itens).
|
|
382
543
|
const { project, error: projectError } = loadProjectConfig();
|
|
@@ -399,8 +560,11 @@ export async function decompose({ issueNumber }) {
|
|
|
399
560
|
// Coletor de uso de IA — o finally registra o custo já incorrido mesmo nos
|
|
400
561
|
// fluxos que retornam cedo (ex.: abort da crítica grave) ou que falham.
|
|
401
562
|
const usageEntries = [];
|
|
402
|
-
const ctx = { token, projectToken, owner, repo, issue, issueNumber, project, etapaField, statusField, usage: usageEntries };
|
|
563
|
+
const ctx = { token, projectToken, owner, repo, issue, issueNumber, type, project, etapaField, statusField, usage: usageEntries };
|
|
403
564
|
try {
|
|
565
|
+
// Limpeza do re-decompose forçado ANTES de gerar: fecha as sub-issues da
|
|
566
|
+
// decomposição anterior para o board não ficar com dois conjuntos.
|
|
567
|
+
if (forced) await closeStaleSubIssues(ctx, subIssues);
|
|
404
568
|
if (type === 'Feature') await decomposeFeature(ctx);
|
|
405
569
|
else if (type === 'RFC') await decomposeRFC(ctx);
|
|
406
570
|
} finally {
|
|
@@ -8,6 +8,7 @@ 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';
|
|
11
12
|
import { buildTechContext } from '../lib/tech-context.mjs';
|
|
12
13
|
|
|
13
14
|
// Aviso anexado ao comentário quando o lint de idioma ainda reprova após o
|
|
@@ -41,7 +42,7 @@ Regras OBRIGATÓRIAS:
|
|
|
41
42
|
- Forneça detalhes acionáveis: caminhos exatos de endpoints, nomes de DTOs, constraints de banco.
|
|
42
43
|
- Responda APENAS com o conteúdo do plan.md, sem texto adicional.`;
|
|
43
44
|
|
|
44
|
-
export async function generatePlan({ issueNumber }) {
|
|
45
|
+
export async function generatePlan({ issueNumber, force = false }) {
|
|
45
46
|
const token = await resolveToken();
|
|
46
47
|
const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
47
48
|
|
|
@@ -69,6 +70,11 @@ export async function generatePlan({ issueNumber }) {
|
|
|
69
70
|
return;
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
// Ver generate-spec: não há guard aqui — a label já regera e sobrescreve.
|
|
74
|
+
const forced = isForced({ labels: issue.labels || [], flag: force });
|
|
75
|
+
await consumeForceLabel(token, owner, repo, parseInt(issueNumber, 10));
|
|
76
|
+
if (forced) console.log('Modo forçado ativo — plan.md será regerado (sobrescreve o existente).');
|
|
77
|
+
|
|
72
78
|
const slug = slugify(issue.title);
|
|
73
79
|
const featureDir = `docs/features/${slug}`;
|
|
74
80
|
const filePath = `${featureDir}/plan.md`;
|
|
@@ -5,6 +5,7 @@ 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';
|
|
8
9
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
9
10
|
import { allowsSpecPlan, SPEC_PLAN_EXCLUDED_TYPES, TARGET_LANGUAGE } from '../config.mjs';
|
|
10
11
|
|
|
@@ -41,7 +42,7 @@ Regras:
|
|
|
41
42
|
- Seja específico e detalhado em cada seção.
|
|
42
43
|
- Responda APENAS com o conteúdo do spec.md, sem texto adicional.`;
|
|
43
44
|
|
|
44
|
-
export async function generateSpec({ issueNumber }) {
|
|
45
|
+
export async function generateSpec({ issueNumber, force = false }) {
|
|
45
46
|
const token = await resolveToken();
|
|
46
47
|
const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
47
48
|
|
|
@@ -70,6 +71,13 @@ export async function generateSpec({ issueNumber }) {
|
|
|
70
71
|
return;
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
// A geração de spec/plan não tem guard — re-acionar a label já regera e
|
|
75
|
+
// sobrescreve o arquivo. O force existe por simetria com o decompose (e para
|
|
76
|
+
// a label ser consumida quando aplicada aos três de uma vez).
|
|
77
|
+
const forced = isForced({ labels: issue.labels || [], flag: force });
|
|
78
|
+
await consumeForceLabel(token, owner, repo, parseInt(issueNumber, 10));
|
|
79
|
+
if (forced) console.log('Modo forçado ativo — spec.md será regerado (sobrescreve o existente).');
|
|
80
|
+
|
|
73
81
|
const slug = slugify(issue.title);
|
|
74
82
|
const featureDir = `docs/features/${slug}`;
|
|
75
83
|
const filePath = `${featureDir}/spec.md`;
|
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,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,12 @@ 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: no **decompose**, ignora o guard e **fecha as sub-issues da decomposição anterior** antes de gerar as novas (veja *Guard de idempotência*); em **spec** e **plan**, não muda nada na prática — eles já regeram e sobrescrevem o arquivo a cada acionamento da label.
|
|
113
|
+
|
|
108
114
|
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
115
|
|
|
110
116
|
---
|
|
@@ -183,6 +189,7 @@ Mesmas flags do `issue` (exceto `--type`, fixo em `feature`). Mantido para o flu
|
|
|
183
189
|
| Flag | Tipo | Descrição |
|
|
184
190
|
|------|------|-----------|
|
|
185
191
|
| `--issue-number <n>` | string (obrigatório) | Número da issue no GitHub. |
|
|
192
|
+
| `--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
193
|
|
|
187
194
|
> ⚠️ 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
195
|
>
|
|
@@ -243,7 +250,11 @@ Após o `generate-plan` e **antes** da criação de issues no `decompose`, um se
|
|
|
243
250
|
|
|
244
251
|
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
252
|
|
|
246
|
-
**Para forçar um re-decompose
|
|
253
|
+
**Para forçar um re-decompose**, adicione a label `spec-wave:force` junto com o gatilho:
|
|
254
|
+
```bash
|
|
255
|
+
gh issue edit <n> --add-label "spec-wave:force" --add-label "spec-wave:decompose"
|
|
256
|
+
```
|
|
257
|
+
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
258
|
|
|
248
259
|
### Dependências entre Stories (`Depende de: #N`)
|
|
249
260
|
|
|
@@ -405,7 +416,8 @@ Inicia a geração da **especificação funcional** para uma Feature. É o **pri
|
|
|
405
416
|
```
|
|
406
417
|
3. Informe: "Label `spec-wave:spec` adicionada. O GitHub Action `generate-spec.yml` irá gerar o `spec.md` automaticamente."
|
|
407
418
|
4. Após a conclusão, ofereça revisar o spec.md gerado em `docs/features/<slug>/spec.md`.
|
|
408
|
-
5.
|
|
419
|
+
5. **Para regerar** (a spec já existe e o usuário quer outra versão): basta re-adicionar a label — o Action sobrescreve o arquivo. A label `spec-wave:force` é aceita, mas aqui não muda nada; **avise que o spec.md atual será perdido** (inclusive edições manuais) e confirme antes.
|
|
420
|
+
6. Próximo passo: gerar o plano técnico — mova para **📋 Plan** e use `/spec-wave plan <número>`.
|
|
409
421
|
|
|
410
422
|
---
|
|
411
423
|
|
|
@@ -424,7 +436,8 @@ O plano técnico segue o schema do RFC-002 §3.2: **Estratégia Técnica** (com
|
|
|
424
436
|
```
|
|
425
437
|
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
438
|
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.
|
|
439
|
+
6. **Para regerar**: re-adicione a label — o Action sobrescreve o `plan.md` (mesma ressalva do spec: edições manuais se perdem, confirme antes).
|
|
440
|
+
7. Próximo passo: validar a Feature — mova para **✅ Ready** e use `/spec-wave ready <número>`.
|
|
428
441
|
|
|
429
442
|
---
|
|
430
443
|
|
|
@@ -516,7 +529,7 @@ Para qualquer outro tipo (Spike, Bug, Story, Task, …) o Action **recusa** e co
|
|
|
516
529
|
```
|
|
517
530
|
3. Informe: "Decomposição iniciada — Feature gera Stories+Tasks; RFC gera Tasks."
|
|
518
531
|
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
|
|
532
|
+
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
533
|
|
|
521
534
|
---
|
|
522
535
|
|