@spec-wave/cli 0.13.0 → 0.14.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 +39 -6
- package/bin/spec-wave.mjs +14 -1
- package/package.json +1 -1
- package/src/commands/code-review.mjs +5 -8
- package/src/commands/decompose.mjs +412 -130
- package/src/commands/dev-agent.mjs +3 -2
- package/src/commands/doctor.mjs +239 -9
- package/src/commands/generate-plan.mjs +105 -30
- package/src/commands/generate-spec.mjs +17 -5
- package/src/commands/implement.mjs +39 -15
- package/src/commands/info.mjs +4 -3
- package/src/commands/issue.mjs +4 -4
- package/src/commands/move.mjs +162 -0
- package/src/commands/order.mjs +1 -12
- package/src/commands/qa.mjs +5 -8
- package/src/commands/refresh.mjs +4 -3
- package/src/commands/story.mjs +1 -12
- package/src/commands/task.mjs +1 -11
- package/src/commands/update.mjs +43 -19
- package/src/commands/validate.mjs +37 -22
- package/src/config.mjs +40 -1
- package/src/lib/board.mjs +88 -26
- package/src/lib/claude.mjs +315 -70
- package/src/lib/critique.mjs +391 -91
- package/src/lib/decomposition-doc.mjs +451 -0
- package/src/lib/implement-board.mjs +14 -1
- package/src/lib/project-root.mjs +93 -0
- package/src/lib/templates.mjs +53 -0
- package/src/setup/files.mjs +3 -10
- package/src/templates/skill/SKILL.md +143 -27
- package/src/templates/workflows/code-review.yml +1 -1
- package/src/templates/workflows/decompose.yml +20 -6
- package/src/templates/workflows/generate-plan.yml +1 -1
- package/src/templates/workflows/generate-spec.yml +1 -1
- package/src/templates/workflows/qa.yml +1 -1
- package/src/templates/workflows/validate.yml +1 -1
|
@@ -1,16 +1,46 @@
|
|
|
1
|
-
|
|
1
|
+
// Decomposição de Feature → Stories(+Tasks) e RFC → Tasks, em DUAS etapas.
|
|
2
|
+
//
|
|
3
|
+
// Antes era atômico: gerava o JSON, submetia à crítica, e se a crítica reprovasse
|
|
4
|
+
// descartava o rascunho inteiro. Os achados citavam "Story 5, task 1" de um
|
|
5
|
+
// artefato que não existia em lugar nenhum — nem na issue, nem no repo, nem nos
|
|
6
|
+
// logs. E como cada tentativa regenerava do zero, cada execução produzia uma
|
|
7
|
+
// lista diferente de achados: cinco tentativas, cinco listas.
|
|
8
|
+
//
|
|
9
|
+
// Agora:
|
|
10
|
+
// label spec-wave:decompose → grava/re-critica docs/**/decomposition.md
|
|
11
|
+
// label spec-wave:decompose-apply → cria as issues a partir do rascunho revisado
|
|
12
|
+
//
|
|
13
|
+
// A crítica passa a apontar para um arquivo ESTÁVEL, versionado, com âncoras
|
|
14
|
+
// ("Story 3", "Task 3.2"), e o humano corrige o artefato em vez de tentar
|
|
15
|
+
// influenciar indiretamente pelo plan.md. Um rascunho existente NUNCA é
|
|
16
|
+
// regenerado: re-aplicar :decompose critica o arquivo como está, preservando as
|
|
17
|
+
// edições humanas. Para regerar do zero, apague o arquivo.
|
|
18
|
+
|
|
19
|
+
import { execSync } from 'node:child_process';
|
|
20
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
2
22
|
import { resolveToken } from '../api/auth.mjs';
|
|
3
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
getIssue, createIssue, removeLabel, addLabel, commentOnIssue, addBlockedBy, listIssueComments,
|
|
25
|
+
} from '../api/github-rest.mjs';
|
|
4
26
|
import { addSubIssue, listSubIssues } from '../api/github-graphql.mjs';
|
|
5
27
|
import { loadProjectConfig, resolveField, advanceToStage } from '../lib/board.mjs';
|
|
6
28
|
import { generateDocument } from '../lib/claude.mjs';
|
|
7
|
-
import { runCritique } from '../lib/critique.mjs';
|
|
29
|
+
import { runCritique, resolveCritiqueAttempt, renderNeedsHumanComment } from '../lib/critique.mjs';
|
|
8
30
|
import { recordUsage } from '../lib/usage-report.mjs';
|
|
9
31
|
import { formatDependencyLine } from '../lib/dependencies.mjs';
|
|
10
32
|
import { lintLanguage } from '../lib/output-lint.mjs';
|
|
11
33
|
import { slugify } from '../lib/slugify.mjs';
|
|
12
34
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
13
|
-
import {
|
|
35
|
+
import { loadConfig } from '../lib/project-root.mjs';
|
|
36
|
+
import {
|
|
37
|
+
renderDecompositionDoc, parseDecompositionDoc, DECOMPOSITION_FILE,
|
|
38
|
+
} from '../lib/decomposition-doc.mjs';
|
|
39
|
+
import {
|
|
40
|
+
DECOMPOSE_TARGETS, LABEL_DECOMPOSE, LABEL_DECOMPOSE_APPLY, LABEL_DECOMPOSE_READY,
|
|
41
|
+
LABEL_DECOMPOSED, LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN, TARGET_LANGUAGE,
|
|
42
|
+
STAGE_READY, PROGRESS_TODO, DEFAULT_MAX_CRITIQUE_ATTEMPTS, labelNames,
|
|
43
|
+
} from '../config.mjs';
|
|
14
44
|
|
|
15
45
|
// Adiciona a issue ao board na Etapa ✅ Ready / Status Todo. Best-effort; a
|
|
16
46
|
// Etapa nunca retrocede (advanceToStage não toca itens já adiante).
|
|
@@ -19,6 +49,20 @@ async function moveToReady(token, project, etapaField, statusField, nodeId) {
|
|
|
19
49
|
await advanceToStage(token, project, etapaField, statusField, nodeId, STAGE_READY, PROGRESS_TODO);
|
|
20
50
|
}
|
|
21
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Parada por decisão do fluxo (crítica grave, crítica indisponível, teto de
|
|
54
|
+
* tentativas): o comentário já foi postado, o catch externo não deve duplicá-lo,
|
|
55
|
+
* mas o erro PRECISA propagar. Antes esses caminhos faziam `return`, e a Action
|
|
56
|
+
* terminava verde tendo criado zero Stories — só se descobria olhando as labels.
|
|
57
|
+
*/
|
|
58
|
+
class DecomposeBlockedError extends Error {
|
|
59
|
+
constructor(message) {
|
|
60
|
+
super(message);
|
|
61
|
+
this.name = 'DecomposeBlockedError';
|
|
62
|
+
this.blocked = true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
22
66
|
// Dobra as barras invertidas que NÃO iniciam um escape válido de JSON. O corpo
|
|
23
67
|
// das tasks costuma trazer trecho de shell/YAML/regex ("\d+", "gradlew \" no fim
|
|
24
68
|
// da linha) e o modelo emite a barra crua: `JSON.parse` morre com "Bad escaped
|
|
@@ -79,6 +123,22 @@ export function parseModelJson(raw) {
|
|
|
79
123
|
// Prefixo de título das sub-issues geradas por cada tipo decompoível.
|
|
80
124
|
const CHILD_PREFIX = { Feature: '[STORY]', RFC: '[TASK]' };
|
|
81
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Modo do run (função PURA — testável).
|
|
128
|
+
*
|
|
129
|
+
* A flag `--apply` vem do workflow, onde a label que disparou é a fonte de
|
|
130
|
+
* verdade. O fallback por label cobre a execução manual e o retry de runner.
|
|
131
|
+
*
|
|
132
|
+
* @param {object} params
|
|
133
|
+
* @param {Array<string|{name:string}>} [params.labels] labels da issue
|
|
134
|
+
* @param {boolean} [params.apply] flag --apply
|
|
135
|
+
* @returns {'draft'|'apply'}
|
|
136
|
+
*/
|
|
137
|
+
export function resolveDecomposeMode({ labels = [], apply = false } = {}) {
|
|
138
|
+
if (apply) return 'apply';
|
|
139
|
+
return labelNames(labels).includes(LABEL_DECOMPOSE_APPLY) ? 'apply' : 'draft';
|
|
140
|
+
}
|
|
141
|
+
|
|
82
142
|
/**
|
|
83
143
|
* Guard de idempotência do decompose (função PURA — testável).
|
|
84
144
|
*
|
|
@@ -86,6 +146,9 @@ const CHILD_PREFIX = { Feature: '[STORY]', RFC: '[TASK]' };
|
|
|
86
146
|
* sub-issues já contêm um item do tipo-alvo (Feature → algum `[STORY]` no
|
|
87
147
|
* título; RFC → algum `[TASK]`). Sub-issues de outro tipo não contam.
|
|
88
148
|
*
|
|
149
|
+
* `spec-wave:decompose-ready` de propósito NÃO entra aqui: é estado de rascunho
|
|
150
|
+
* pendente, não de decomposição feita — re-aplicar `:decompose` deve re-criticar.
|
|
151
|
+
*
|
|
89
152
|
* @param {object} params
|
|
90
153
|
* @param {Array<string|{name: string}>} [params.labels] labels da issue
|
|
91
154
|
* @param {Array<{ number?: number, title?: string }>} [params.subIssues] sub-issues existentes
|
|
@@ -93,9 +156,7 @@ const CHILD_PREFIX = { Feature: '[STORY]', RFC: '[TASK]' };
|
|
|
93
156
|
* @returns {{ skip: boolean, reason: string }}
|
|
94
157
|
*/
|
|
95
158
|
export function shouldSkipDecompose({ labels = [], subIssues = [], type } = {}) {
|
|
96
|
-
const names = labels
|
|
97
|
-
.map(l => (typeof l === 'string' ? l : l?.name))
|
|
98
|
-
.filter(Boolean);
|
|
159
|
+
const names = labelNames(labels);
|
|
99
160
|
if (names.includes(LABEL_DECOMPOSED)) {
|
|
100
161
|
return { skip: true, reason: `a issue já tem a label \`${LABEL_DECOMPOSED}\`` };
|
|
101
162
|
}
|
|
@@ -112,6 +173,14 @@ export function shouldSkipDecompose({ labels = [], subIssues = [], type } = {})
|
|
|
112
173
|
return { skip: false, reason: '' };
|
|
113
174
|
}
|
|
114
175
|
|
|
176
|
+
// Diretório do documento por tipo. Feature usa o mesmo docs/features/<slug> da
|
|
177
|
+
// spec/plan; RFC ganha o seu, já que não passa por spec/plan.
|
|
178
|
+
function resolveDocDir(root, issue, type) {
|
|
179
|
+
const slug = slugify(issue.title);
|
|
180
|
+
const rel = type === 'RFC' ? `docs/rfcs/${slug}` : `docs/features/${slug}`;
|
|
181
|
+
return { slug, rel, dir: path.resolve(root || process.cwd(), rel) };
|
|
182
|
+
}
|
|
183
|
+
|
|
115
184
|
// Lint de idioma sobre títulos+corpos gerados; retorna aviso pronto para
|
|
116
185
|
// anexar ao comentário final ('' se limpo).
|
|
117
186
|
function formatItemsLintWarning(texts) {
|
|
@@ -124,6 +193,20 @@ function formatItemsLintWarning(texts) {
|
|
|
124
193
|
return `\n\n⚠️ possíveis artefatos de idioma nos itens gerados: ${excerpts}`;
|
|
125
194
|
}
|
|
126
195
|
|
|
196
|
+
// Grava e commita o rascunho — mesmo padrão do generate-plan (o workflow roda
|
|
197
|
+
// com contents: write e checkout com token).
|
|
198
|
+
function commitFile(filePath, content, message) {
|
|
199
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
200
|
+
writeFileSync(filePath, content, 'utf-8');
|
|
201
|
+
const git = (cmd) => execSync(cmd, { stdio: 'inherit' });
|
|
202
|
+
git('git config user.email "spec-wave[bot]@github.com"');
|
|
203
|
+
git('git config user.name "spec-wave[bot]"');
|
|
204
|
+
git(`git add "${filePath}"`);
|
|
205
|
+
git(`git commit -m "${message}"`);
|
|
206
|
+
git('git pull --rebase');
|
|
207
|
+
git('git push');
|
|
208
|
+
}
|
|
209
|
+
|
|
127
210
|
const FEATURE_SYSTEM_PROMPT = `Você é um Tech Lead experiente em decomposição de trabalho ágil.
|
|
128
211
|
A partir da Feature fornecida (com spec.md e plan.md), gere uma lista de Stories e Tasks.
|
|
129
212
|
|
|
@@ -172,65 +255,239 @@ Regras:
|
|
|
172
255
|
- "body" detalhado e acionável.
|
|
173
256
|
- Gere entre 3 e 10 Tasks concretas que, juntas, cubram o RFC.`;
|
|
174
257
|
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// Etapa 1: rascunho (label spec-wave:decompose)
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
async function draftDecomposition(ctx) {
|
|
263
|
+
const { token, owner, repo, issue, issueNumber, type, labels, usage, docDir, docPath, docRel } = ctx;
|
|
264
|
+
const number = parseInt(issueNumber, 10);
|
|
265
|
+
const kind = DECOMPOSE_TARGETS[type]; // Feature → 'stories'; RFC → 'tasks'
|
|
266
|
+
const blobUrl = `https://github.com/${owner}/${repo}/blob/main/${docRel}`;
|
|
267
|
+
|
|
268
|
+
const specPath = path.join(docDir, 'spec.md');
|
|
269
|
+
const planPath = path.join(docDir, 'plan.md');
|
|
270
|
+
const specContent = existsSync(specPath) ? readFileSync(specPath, 'utf-8') : null;
|
|
271
|
+
const planContent = existsSync(planPath) ? readFileSync(planPath, 'utf-8') : null;
|
|
272
|
+
|
|
273
|
+
// Rascunho existente é preservado COMO ESTÁ: o humano corrige o arquivo e
|
|
274
|
+
// re-aplica a label para uma nova crítica. Regenerar aqui apagaria a correção
|
|
275
|
+
// — é justamente o que fazia o ciclo não convergir.
|
|
276
|
+
let markdown;
|
|
277
|
+
if (existsSync(docPath)) {
|
|
278
|
+
console.log(`Rascunho encontrado em ${docRel} — criticando o arquivo como está (sem regerar).`);
|
|
279
|
+
markdown = readFileSync(docPath, 'utf-8');
|
|
280
|
+
} else {
|
|
281
|
+
console.log(`Gerando rascunho de decomposição para ${type}: ${issue.title}`);
|
|
282
|
+
const userContent = type === 'RFC'
|
|
283
|
+
? [
|
|
284
|
+
`RFC: ${issue.title}`,
|
|
285
|
+
`Issue #${issueNumber}`,
|
|
286
|
+
`\n## Descrição\n${issue.body || '(sem descrição)'}`,
|
|
287
|
+
].join('\n')
|
|
288
|
+
: [
|
|
289
|
+
`Feature: ${issue.title}`,
|
|
290
|
+
`Issue #${issueNumber}`,
|
|
291
|
+
`\n## spec.md\n${specContent || '(spec.md não encontrado)'}`,
|
|
292
|
+
`\n## plan.md\n${planContent || '(plan.md não encontrado)'}`,
|
|
293
|
+
].join('\n');
|
|
294
|
+
|
|
295
|
+
const generated = parseModelJson(await generateDocument(
|
|
296
|
+
type === 'RFC' ? RFC_SYSTEM_PROMPT : FEATURE_SYSTEM_PROMPT,
|
|
297
|
+
userContent,
|
|
298
|
+
{ action: 'decompose', labels, usage }
|
|
299
|
+
));
|
|
300
|
+
|
|
301
|
+
markdown = renderDecompositionDoc({
|
|
302
|
+
title: issue.title,
|
|
303
|
+
issueNumber: number,
|
|
304
|
+
kind,
|
|
305
|
+
stories: generated.stories || [],
|
|
306
|
+
tasks: generated.tasks || [],
|
|
307
|
+
});
|
|
308
|
+
commitFile(docPath, markdown, `docs: rascunho de decomposição de ${docRel} [spec-wave]`);
|
|
309
|
+
console.log(`Rascunho commitado em ${docRel}.`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Valida a estrutura ANTES de gastar tokens com a crítica: um arquivo quebrado
|
|
313
|
+
// por edição humana precisa de mensagem clara, não de uma crítica sobre nada.
|
|
314
|
+
let doc;
|
|
315
|
+
try {
|
|
316
|
+
doc = parseDecompositionDoc(markdown);
|
|
317
|
+
} catch (err) {
|
|
318
|
+
await commentOnIssue(token, owner, repo, number,
|
|
319
|
+
`❌ **Não consegui ler o rascunho da decomposição.**\n\n` +
|
|
320
|
+
`\`\`\`\n${err.message}\n\`\`\`\n\n` +
|
|
321
|
+
`Corrija [\`${docRel}\`](${blobUrl}) e reaplique \`${LABEL_DECOMPOSE}\`. ` +
|
|
322
|
+
`Para começar de novo do zero, apague o arquivo.`
|
|
323
|
+
).catch(() => {});
|
|
324
|
+
await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE).catch(() => {});
|
|
325
|
+
throw new DecomposeBlockedError(err.message);
|
|
326
|
+
}
|
|
327
|
+
const itemCount = kind === 'stories'
|
|
328
|
+
? `${doc.stories.length} stories`
|
|
329
|
+
: `${doc.tasks.length} tasks`;
|
|
330
|
+
console.log(`Rascunho válido: ${itemCount}.`);
|
|
331
|
+
|
|
332
|
+
// O RFC não tem spec/plan para auditar contra — a crítica não teria referência.
|
|
333
|
+
if (kind !== 'stories') {
|
|
334
|
+
await finishDraft(ctx, { doc, blobUrl, itemCount, critiqued: false });
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const { attempt, blocked } = resolveCritiqueAttempt({
|
|
339
|
+
comments: ctx.comments, labels, kind: 'stories', maxAttempts: ctx.maxCritiqueAttempts,
|
|
340
|
+
});
|
|
341
|
+
if (blocked) {
|
|
342
|
+
console.log(`Teto de ${ctx.maxCritiqueAttempts} tentativas de crítica atingido — exigindo revisão humana.`);
|
|
343
|
+
await commentOnIssue(token, owner, repo, number, renderNeedsHumanComment({
|
|
344
|
+
kind: 'stories',
|
|
345
|
+
attempt,
|
|
346
|
+
maxAttempts: ctx.maxCritiqueAttempts,
|
|
347
|
+
escalationModel: ctx.escalationModel,
|
|
348
|
+
})).catch(() => {});
|
|
349
|
+
await addLabel(token, owner, repo, number, LABEL_NEEDS_HUMAN);
|
|
350
|
+
await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE);
|
|
351
|
+
throw new DecomposeBlockedError(
|
|
352
|
+
`A crítica reprovou ${ctx.maxCritiqueAttempts - 1}x seguidas. Label ${LABEL_NEEDS_HUMAN} ` +
|
|
353
|
+
'aplicada: o fluxo exige revisão humana antes de continuar.'
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Da segunda tentativa em diante, escala para o modelo mais forte configurado.
|
|
358
|
+
const model = attempt > 1 ? (ctx.escalationModel || undefined) : undefined;
|
|
359
|
+
if (model) console.log(`Tentativa ${attempt}: escalando a crítica para ${model}.`);
|
|
360
|
+
|
|
361
|
+
let critique;
|
|
201
362
|
try {
|
|
202
363
|
critique = await runCritique({
|
|
203
364
|
kind: 'stories',
|
|
204
|
-
spec:
|
|
205
|
-
plan:
|
|
206
|
-
|
|
365
|
+
spec: specContent,
|
|
366
|
+
plan: planContent,
|
|
367
|
+
decomposition: markdown,
|
|
368
|
+
attempt,
|
|
369
|
+
maxAttempts: ctx.maxCritiqueAttempts,
|
|
370
|
+
model,
|
|
371
|
+
labels,
|
|
207
372
|
usage,
|
|
208
373
|
});
|
|
209
374
|
} catch (err) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
375
|
+
// A crítica é o portão que impede stories contraditórias de virarem trabalho.
|
|
376
|
+
// Liberar o rascunho com o portão comprovadamente não executado é exatamente
|
|
377
|
+
// o que se quer evitar — então aborta, e a Action fica vermelha.
|
|
378
|
+
await commentOnIssue(token, owner, repo, number,
|
|
379
|
+
`❌ **A crítica adversarial não concluiu.**\n\n` +
|
|
380
|
+
`\`\`\`\n${err.message}\n\`\`\`\n\n` +
|
|
381
|
+
`O rascunho está commitado em [\`${docRel}\`](${blobUrl}), mas **nada foi criado** — ` +
|
|
382
|
+
`sem crítica não há garantia de que as Stories respeitam a spec. ` +
|
|
383
|
+
`Reaplique \`${LABEL_DECOMPOSE}\` para tentar de novo.`
|
|
214
384
|
).catch(() => {});
|
|
385
|
+
await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE).catch(() => {});
|
|
386
|
+
throw new DecomposeBlockedError(`Crítica adversarial não concluiu: ${err.message}`);
|
|
215
387
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
388
|
+
|
|
389
|
+
await commentOnIssue(token, owner, repo, number, critique.markdown)
|
|
390
|
+
.catch(err => console.warn(`Falha ao comentar a crítica: ${err.message}`));
|
|
391
|
+
|
|
392
|
+
if (critique.grave) {
|
|
393
|
+
console.log('Crítica adversarial apontou findings GRAVES — nenhum item foi criado.');
|
|
394
|
+
await addLabel(token, owner, repo, number, LABEL_CRITIQUE_FAILED);
|
|
395
|
+
await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE_READY).catch(() => {});
|
|
396
|
+
await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE);
|
|
397
|
+
throw new DecomposeBlockedError(
|
|
398
|
+
`A crítica adversarial apontou findings graves no rascunho (tentativa ${attempt}). ` +
|
|
399
|
+
`Nenhuma issue foi criada. Corrija ${docRel} e reaplique ${LABEL_DECOMPOSE}.`
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Crítica limpa: remove o bloqueio anterior, que também zera o contador de
|
|
404
|
+
// tentativas na próxima rodada (ver resolveCritiqueAttempt).
|
|
405
|
+
await removeLabel(token, owner, repo, number, LABEL_CRITIQUE_FAILED).catch(() => {});
|
|
406
|
+
await finishDraft(ctx, { doc, blobUrl, itemCount, critiqued: true });
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Fecho comum do rascunho aprovado: libera para revisão humana e o apply.
|
|
410
|
+
async function finishDraft({ token, owner, repo, issueNumber, docRel }, { blobUrl, itemCount, critiqued }) {
|
|
411
|
+
const number = parseInt(issueNumber, 10);
|
|
412
|
+
await addLabel(token, owner, repo, number, LABEL_DECOMPOSE_READY);
|
|
413
|
+
await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE);
|
|
414
|
+
await commentOnIssue(token, owner, repo, number,
|
|
415
|
+
`📝 **Rascunho de decomposição pronto para revisão** (${itemCount}).\n\n` +
|
|
416
|
+
`📄 Arquivo: [\`${docRel}\`](${blobUrl})\n\n` +
|
|
417
|
+
(critiqued
|
|
418
|
+
? 'A crítica adversarial não encontrou contradições graves. '
|
|
419
|
+
: '') +
|
|
420
|
+
`**Nada foi criado ainda.** Revise (e edite, se quiser) o arquivo e então crie as issues:\n` +
|
|
421
|
+
`\`\`\`\ngh issue edit ${issueNumber} --add-label "${LABEL_DECOMPOSE_APPLY}"\n\`\`\`\n` +
|
|
422
|
+
`Se preferir uma nova crítica depois de editar, reaplique \`${LABEL_DECOMPOSE}\` — ` +
|
|
423
|
+
'o arquivo é criticado como está, sem ser regerado. Para gerar outro do zero, apague-o.'
|
|
424
|
+
).catch(err => console.warn(`Falha ao comentar o rascunho: ${err.message}`));
|
|
425
|
+
console.log(`Rascunho liberado para revisão: ${itemCount}.`);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
// Etapa 2: aplicação (label spec-wave:decompose-apply)
|
|
430
|
+
// ---------------------------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
async function applyDecomposition(ctx) {
|
|
433
|
+
const { token, owner, repo, issueNumber, docPath, docRel } = ctx;
|
|
434
|
+
const number = parseInt(issueNumber, 10);
|
|
435
|
+
|
|
436
|
+
if (!existsSync(docPath)) {
|
|
437
|
+
await commentOnIssue(token, owner, repo, number,
|
|
438
|
+
`❌ **Não há rascunho de decomposição para aplicar.**\n\n` +
|
|
439
|
+
`Esperava encontrar \`${docRel}\`. Gere o rascunho primeiro:\n` +
|
|
440
|
+
`\`\`\`\ngh issue edit ${issueNumber} --add-label "${LABEL_DECOMPOSE}"\n\`\`\``
|
|
441
|
+
).catch(() => {});
|
|
442
|
+
await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE_APPLY).catch(() => {});
|
|
443
|
+
throw new DecomposeBlockedError(`${docRel} não encontrado — aplique ${LABEL_DECOMPOSE} primeiro.`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
let doc;
|
|
447
|
+
try {
|
|
448
|
+
doc = parseDecompositionDoc(readFileSync(docPath, 'utf-8'));
|
|
449
|
+
} catch (err) {
|
|
450
|
+
await commentOnIssue(token, owner, repo, number,
|
|
451
|
+
`❌ **Não consegui ler o rascunho da decomposição.**\n\n` +
|
|
452
|
+
`\`\`\`\n${err.message}\n\`\`\`\n\n` +
|
|
453
|
+
`Corrija \`${docRel}\` e reaplique \`${LABEL_DECOMPOSE_APPLY}\`.`
|
|
454
|
+
).catch(() => {});
|
|
455
|
+
await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE_APPLY).catch(() => {});
|
|
456
|
+
throw new DecomposeBlockedError(err.message);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// O slug vem do TÍTULO da issue: se a Feature foi retitulada entre o rascunho e
|
|
460
|
+
// o apply, o arquivo encontrado pode ser de outra issue. Aviso, não erro — o
|
|
461
|
+
// humano é quem sabe se houve renomeação.
|
|
462
|
+
if (doc.issueNumber && doc.issueNumber !== number) {
|
|
463
|
+
console.warn(
|
|
464
|
+
`⚠️ ${docRel} foi gerado para a issue #${doc.issueNumber}, não a #${number} ` +
|
|
465
|
+
'(a issue foi retitulada?). Seguindo com o arquivo encontrado.'
|
|
466
|
+
);
|
|
225
467
|
}
|
|
226
468
|
|
|
469
|
+
// Campos do board só são resolvidos aqui: a etapa de rascunho não toca o board.
|
|
470
|
+
const { project, etapaField, statusField } = await resolveBoard(ctx);
|
|
471
|
+
const applyCtx = { ...ctx, project, etapaField, statusField };
|
|
472
|
+
|
|
473
|
+
if (doc.kind === 'tasks') await createTasksFromDoc(applyCtx, doc);
|
|
474
|
+
else await createStoriesFromDoc(applyCtx, doc);
|
|
475
|
+
|
|
476
|
+
await addLabel(token, owner, repo, number, LABEL_DECOMPOSED)
|
|
477
|
+
.catch(err => console.warn(`Falha ao aplicar a label ${LABEL_DECOMPOSED}: ${err.message}`));
|
|
478
|
+
await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE_APPLY);
|
|
479
|
+
await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE_READY).catch(() => {});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function createStoriesFromDoc(ctx, doc) {
|
|
483
|
+
const { token, projectToken, owner, repo, issue, issueNumber, project, etapaField, statusField, docRel } = ctx;
|
|
227
484
|
const featureNodeId = issue.node_id;
|
|
228
485
|
const created = [];
|
|
229
486
|
const createdStories = []; // issues criadas, na ordem dos índices das stories
|
|
230
487
|
const generatedTexts = []; // títulos+corpos para o lint de idioma final
|
|
231
488
|
|
|
232
|
-
for (let i = 0; i <
|
|
233
|
-
const story =
|
|
489
|
+
for (let i = 0; i < doc.stories.length; i++) {
|
|
490
|
+
const story = doc.stories[i];
|
|
234
491
|
console.log(`Criando story: ${story.title}`);
|
|
235
492
|
const storyTitle = `[STORY] ${story.title}`;
|
|
236
493
|
let storyBody = [story.userStory, story.body]
|
|
@@ -238,13 +495,8 @@ async function decomposeFeature(ctx) {
|
|
|
238
495
|
.filter(Boolean)
|
|
239
496
|
.join('\n\n') || '_(sem descrição)_';
|
|
240
497
|
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
// sem dependências. Índices inválidos/futuros são ignorados.
|
|
244
|
-
const depIndexes = Array.isArray(story.dependsOn)
|
|
245
|
-
? [...new Set(story.dependsOn.filter(d => Number.isInteger(d) && d >= 0 && d < i))]
|
|
246
|
-
: (i > 0 ? [i - 1] : []);
|
|
247
|
-
const depIssues = depIndexes.map(idx => createdStories[idx]).filter(Boolean);
|
|
498
|
+
// dependsOn já vem validado pelo parser (0-based, só para trás).
|
|
499
|
+
const depIssues = story.dependsOn.map(idx => createdStories[idx]).filter(Boolean);
|
|
248
500
|
const depLine = formatDependencyLine(depIssues.map(d => d.number));
|
|
249
501
|
if (depLine) storyBody += `\n\n${depLine}`;
|
|
250
502
|
|
|
@@ -299,43 +551,23 @@ async function decomposeFeature(ctx) {
|
|
|
299
551
|
console.warn(`Falha ao mover Feature para "${STAGE_READY}": ${err.message}`);
|
|
300
552
|
}
|
|
301
553
|
|
|
302
|
-
// Marca a Feature como decomposta (guard de idempotência em runs futuros).
|
|
303
|
-
try {
|
|
304
|
-
await addLabel(token, owner, repo, parseInt(issueNumber, 10), LABEL_DECOMPOSED);
|
|
305
|
-
} catch (err) {
|
|
306
|
-
console.warn(`Falha ao aplicar a label ${LABEL_DECOMPOSED}: ${err.message}`);
|
|
307
|
-
}
|
|
308
|
-
await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
|
|
309
554
|
const list = created.map(s => `- ${s.url} — ${s.title}`).join('\n');
|
|
310
|
-
await commentOnIssue(
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
`Foram criados ${decomposition.stories.length} stories e suas tasks:\n\n${list}\n\n` +
|
|
555
|
+
await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10),
|
|
556
|
+
`🔀 **Decomposição aplicada!**\n\n` +
|
|
557
|
+
`A partir de \`${docRel}\` foram criados ${created.length} stories e suas tasks:\n\n${list}\n\n` +
|
|
314
558
|
`Tudo posicionado em **✅ Ready**. Inicie o desenvolvimento com \`npx @spec-wave/cli@latest implement ${issueNumber}\` (Stories em ordem de dependência).` +
|
|
315
559
|
formatItemsLintWarning(generatedTexts)
|
|
316
560
|
);
|
|
317
|
-
console.log(`Decomposição
|
|
561
|
+
console.log(`Decomposição aplicada: ${created.length} stories criadas.`);
|
|
318
562
|
}
|
|
319
563
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const { token, projectToken, owner, repo, issue, issueNumber, project, etapaField, statusField, usage } = ctx;
|
|
324
|
-
console.log(`Decompondo RFC: ${issue.title}`);
|
|
325
|
-
|
|
326
|
-
const userContent = [
|
|
327
|
-
`RFC: ${issue.title}`,
|
|
328
|
-
`Issue #${issueNumber}`,
|
|
329
|
-
`\n## Descrição\n${issue.body || '(sem descrição)'}`,
|
|
330
|
-
].join('\n');
|
|
331
|
-
|
|
332
|
-
const decomposition = parseModelJson(await generateDocument(RFC_SYSTEM_PROMPT, userContent, { action: 'decompose', usage }));
|
|
333
|
-
const rfcNodeId = issue.node_id;
|
|
334
|
-
const tasks = decomposition.tasks || [];
|
|
564
|
+
async function createTasksFromDoc(ctx, doc) {
|
|
565
|
+
const { token, projectToken, owner, repo, issue, issueNumber, project, etapaField, statusField, docRel } = ctx;
|
|
566
|
+
const parentNodeId = issue.node_id;
|
|
335
567
|
const created = [];
|
|
336
|
-
const generatedTexts = [];
|
|
568
|
+
const generatedTexts = [];
|
|
337
569
|
|
|
338
|
-
for (const task of tasks) {
|
|
570
|
+
for (const task of doc.tasks) {
|
|
339
571
|
console.log(`Criando task: ${task.title}`);
|
|
340
572
|
const taskTitle = `[TASK] ${task.title}`;
|
|
341
573
|
const taskBody = `${task.body}\n\n_RFC pai: ${issue.html_url || `#${issueNumber}`}_`;
|
|
@@ -344,7 +576,7 @@ async function decomposeRFC(ctx) {
|
|
|
344
576
|
generatedTexts.push(taskTitle, taskBody);
|
|
345
577
|
|
|
346
578
|
try {
|
|
347
|
-
await addSubIssue(token,
|
|
579
|
+
await addSubIssue(token, parentNodeId, createdTask.nodeId);
|
|
348
580
|
} catch (err) {
|
|
349
581
|
console.warn(` Task #${createdTask.number} criada, mas falhou ao vincular ao RFC: ${err.message}`);
|
|
350
582
|
}
|
|
@@ -355,25 +587,42 @@ async function decomposeRFC(ctx) {
|
|
|
355
587
|
}
|
|
356
588
|
}
|
|
357
589
|
|
|
358
|
-
// Marca o RFC como decomposto (guard de idempotência em runs futuros).
|
|
359
|
-
try {
|
|
360
|
-
await addLabel(token, owner, repo, parseInt(issueNumber, 10), LABEL_DECOMPOSED);
|
|
361
|
-
} catch (err) {
|
|
362
|
-
console.warn(`Falha ao aplicar a label ${LABEL_DECOMPOSED}: ${err.message}`);
|
|
363
|
-
}
|
|
364
|
-
await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
|
|
365
590
|
const list = created.map(t => `- ${t.url} — ${t.title}`).join('\n');
|
|
366
|
-
await commentOnIssue(
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
`Foram criadas ${created.length} tasks:\n\n${list}\n\n` +
|
|
591
|
+
await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10),
|
|
592
|
+
`🔀 **Decomposição do RFC aplicada!**\n\n` +
|
|
593
|
+
`A partir de \`${docRel}\` foram criadas ${created.length} tasks:\n\n${list}\n\n` +
|
|
370
594
|
`Tudo posicionado em **✅ Ready**. Inicie o desenvolvimento com \`npx @spec-wave/cli@latest implement <task>\`.` +
|
|
371
595
|
formatItemsLintWarning(generatedTexts)
|
|
372
596
|
);
|
|
373
|
-
console.log(`Decomposição
|
|
597
|
+
console.log(`Decomposição aplicada: ${created.length} tasks criadas.`);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Projeto + campos Etapa/Status do board (reutilizados em todos os itens).
|
|
601
|
+
async function resolveBoard({ projectToken, root }) {
|
|
602
|
+
const { project, error: projectError } = loadProjectConfig({ cwd: root || process.cwd() });
|
|
603
|
+
if (projectError) console.warn(`${projectError} — itens criados não serão posicionados no board.`);
|
|
604
|
+
let etapaField = null;
|
|
605
|
+
let statusField = null;
|
|
606
|
+
if (project?.id) {
|
|
607
|
+
try {
|
|
608
|
+
etapaField = await resolveField(projectToken, project, 'Etapa');
|
|
609
|
+
} catch (err) {
|
|
610
|
+
console.warn(`Não foi possível resolver campo Etapa do board: ${err.message}`);
|
|
611
|
+
}
|
|
612
|
+
try {
|
|
613
|
+
statusField = await resolveField(projectToken, project, 'Status');
|
|
614
|
+
} catch (err) {
|
|
615
|
+
console.warn(`Não foi possível resolver campo Status do board: ${err.message}`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return { project, etapaField, statusField };
|
|
374
619
|
}
|
|
375
620
|
|
|
376
|
-
|
|
621
|
+
// ---------------------------------------------------------------------------
|
|
622
|
+
// Entrada
|
|
623
|
+
// ---------------------------------------------------------------------------
|
|
624
|
+
|
|
625
|
+
export async function decompose({ issueNumber, apply = false }) {
|
|
377
626
|
const token = await resolveToken();
|
|
378
627
|
// PROJECT_TOKEN deve ter scope "project" para atualizar GitHub Projects v2.
|
|
379
628
|
// Fallback para GITHUB_TOKEN (só funciona em repos pessoais sem org restrictions).
|
|
@@ -388,21 +637,36 @@ export async function decompose({ issueNumber }) {
|
|
|
388
637
|
);
|
|
389
638
|
}
|
|
390
639
|
|
|
391
|
-
const
|
|
640
|
+
const number = parseInt(issueNumber, 10);
|
|
641
|
+
const issue = await getIssue(token, owner, repo, number);
|
|
392
642
|
const type = detectIssueType(issue);
|
|
643
|
+
const labels = labelNames(issue);
|
|
644
|
+
const mode = resolveDecomposeMode({ labels, apply });
|
|
645
|
+
const trigger = mode === 'apply' ? LABEL_DECOMPOSE_APPLY : LABEL_DECOMPOSE;
|
|
393
646
|
|
|
394
647
|
// Só Feature (→ Stories) e RFC (→ Tasks) podem ser decompostos.
|
|
395
648
|
if (!DECOMPOSE_TARGETS[type]) {
|
|
396
649
|
console.log(`Issue #${issueNumber} é ${type || 'desconhecido'} — decompose não se aplica.`);
|
|
397
|
-
await removeLabel(token, owner, repo,
|
|
398
|
-
await commentOnIssue(
|
|
399
|
-
token, owner, repo, parseInt(issueNumber, 10),
|
|
650
|
+
await removeLabel(token, owner, repo, number, trigger);
|
|
651
|
+
await commentOnIssue(token, owner, repo, number,
|
|
400
652
|
`ℹ️ **decompose não se aplica a ${type || 'este tipo'}.** ` +
|
|
401
653
|
`Use em **Features** (gera Stories + Tasks) ou **RFCs** (gera Tasks).`
|
|
402
654
|
).catch(() => {});
|
|
403
655
|
return;
|
|
404
656
|
}
|
|
405
657
|
|
|
658
|
+
// Revisão humana pendente: nada roda até a label sair.
|
|
659
|
+
if (labels.includes(LABEL_NEEDS_HUMAN)) {
|
|
660
|
+
console.log(`Decompose ignorado: a issue tem a label ${LABEL_NEEDS_HUMAN}.`);
|
|
661
|
+
await removeLabel(token, owner, repo, number, trigger);
|
|
662
|
+
await commentOnIssue(token, owner, repo, number,
|
|
663
|
+
`⏸️ **decompose parado:** a issue tem a label \`${LABEL_NEEDS_HUMAN}\`, aplicada depois de ` +
|
|
664
|
+
'reprovas consecutivas da crítica. Corrija os documentos e remova a label ' +
|
|
665
|
+
`(e a \`${LABEL_CRITIQUE_FAILED}\`) para retomar o fluxo.`
|
|
666
|
+
).catch(() => {});
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
|
|
406
670
|
// Guard de idempotência: label spec-wave:decomposed ou sub-issues do
|
|
407
671
|
// tipo-alvo já existentes → não re-decompõe (evita duplicar stories/tasks).
|
|
408
672
|
let subIssues = [];
|
|
@@ -412,45 +676,63 @@ export async function decompose({ issueNumber }) {
|
|
|
412
676
|
console.warn(`Não foi possível listar sub-issues: ${err.message} — seguindo sem o guard de sub-issues.`);
|
|
413
677
|
subIssues = [];
|
|
414
678
|
}
|
|
415
|
-
const guard = shouldSkipDecompose({ labels
|
|
679
|
+
const guard = shouldSkipDecompose({ labels, subIssues, type });
|
|
416
680
|
if (guard.skip) {
|
|
417
681
|
console.log(`Decompose ignorado: ${guard.reason}.`);
|
|
418
|
-
await removeLabel(token, owner, repo,
|
|
419
|
-
await commentOnIssue(
|
|
420
|
-
token, owner, repo, parseInt(issueNumber, 10),
|
|
682
|
+
await removeLabel(token, owner, repo, number, trigger);
|
|
683
|
+
await commentOnIssue(token, owner, repo, number,
|
|
421
684
|
`⏭️ **decompose ignorado:** ${guard.reason}. Para forçar, remova a label ` +
|
|
422
685
|
`\`${LABEL_DECOMPOSED}\` (e apague as sub-issues antigas se quiser re-gerar).`
|
|
423
686
|
).catch(() => {});
|
|
424
687
|
return;
|
|
425
688
|
}
|
|
426
689
|
|
|
427
|
-
//
|
|
428
|
-
const {
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
} catch (err) {
|
|
441
|
-
console.warn(`Não foi possível resolver campo Status do board: ${err.message}`);
|
|
442
|
-
}
|
|
690
|
+
// Config do repo: raiz para os caminhos de documento e escalada da crítica.
|
|
691
|
+
const { config, root } = loadConfig();
|
|
692
|
+
const { rel: docRel, dir: docDir } = resolveDocDir(root, issue, type);
|
|
693
|
+
const docPath = path.join(docDir, DECOMPOSITION_FILE);
|
|
694
|
+
|
|
695
|
+
// Comentários só são necessários no rascunho (contador de tentativas).
|
|
696
|
+
let comments = [];
|
|
697
|
+
if (mode === 'draft') {
|
|
698
|
+
comments = await listIssueComments(token, owner, repo, number)
|
|
699
|
+
.catch(err => {
|
|
700
|
+
console.warn(`Não foi possível listar comentários: ${err.message} — contador de tentativas em 1.`);
|
|
701
|
+
return [];
|
|
702
|
+
});
|
|
443
703
|
}
|
|
444
704
|
|
|
445
705
|
// Coletor de uso de IA — o finally registra o custo já incorrido mesmo nos
|
|
446
|
-
// fluxos que
|
|
706
|
+
// fluxos que abortam (ex.: crítica grave) ou que falham.
|
|
447
707
|
const usageEntries = [];
|
|
448
|
-
const ctx = {
|
|
708
|
+
const ctx = {
|
|
709
|
+
token, projectToken, owner, repo, issue, issueNumber, type, labels, comments,
|
|
710
|
+
root, docDir, docPath, docRel: `${docRel}/${DECOMPOSITION_FILE}`,
|
|
711
|
+
escalationModel: config?.ai?.escalationModel || null,
|
|
712
|
+
maxCritiqueAttempts:
|
|
713
|
+
Number.isInteger(config?.ai?.maxCritiqueAttempts) && config.ai.maxCritiqueAttempts > 0
|
|
714
|
+
? config.ai.maxCritiqueAttempts
|
|
715
|
+
: DEFAULT_MAX_CRITIQUE_ATTEMPTS,
|
|
716
|
+
usage: usageEntries,
|
|
717
|
+
};
|
|
718
|
+
|
|
449
719
|
try {
|
|
450
|
-
if (
|
|
451
|
-
else
|
|
720
|
+
if (mode === 'apply') await applyDecomposition(ctx);
|
|
721
|
+
else await draftDecomposition(ctx);
|
|
722
|
+
} catch (err) {
|
|
723
|
+
// Paradas por decisão do fluxo já comentaram na issue; erros inesperados não.
|
|
724
|
+
if (!err.blocked) {
|
|
725
|
+
await removeLabel(token, owner, repo, number, trigger).catch(() => {});
|
|
726
|
+
await commentOnIssue(token, owner, repo, number,
|
|
727
|
+
`❌ **Falha no decompose (${mode === 'apply' ? 'aplicação' : 'rascunho'}).**\n\n` +
|
|
728
|
+
`\`\`\`\n${err.message}\n\`\`\`\n\n` +
|
|
729
|
+
`A label \`${trigger}\` foi removida para destravar o gatilho — ` +
|
|
730
|
+
'adicione-a de novo para tentar outra vez.'
|
|
731
|
+
).catch(() => {});
|
|
732
|
+
}
|
|
733
|
+
throw err;
|
|
452
734
|
} finally {
|
|
453
735
|
// Best-effort: nunca propaga erro (ver recordUsage).
|
|
454
|
-
await recordUsage({ token, owner, repo, issueNumber:
|
|
736
|
+
await recordUsage({ token, owner, repo, issueNumber: number, entries: usageEntries });
|
|
455
737
|
}
|
|
456
738
|
}
|