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