@spec-wave/cli 0.12.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 -27
- package/bin/spec-wave.mjs +14 -4
- package/package.json +1 -1
- package/src/api/github-graphql.mjs +0 -4
- package/src/api/github-rest.mjs +0 -13
- package/src/commands/code-review.mjs +5 -8
- package/src/commands/decompose.mjs +410 -251
- package/src/commands/dev-agent.mjs +3 -2
- package/src/commands/doctor.mjs +239 -9
- package/src/commands/generate-plan.mjs +111 -51
- package/src/commands/generate-spec.mjs +20 -22
- package/src/commands/implement.mjs +46 -24
- 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 +47 -35
- package/src/config.mjs +40 -6
- 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 +137 -61
- 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
- package/src/lib/feature-docs.mjs +0 -89
- package/src/lib/force.mjs +0 -34
|
@@ -1,20 +1,45 @@
|
|
|
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 {
|
|
4
|
-
|
|
23
|
+
import {
|
|
24
|
+
getIssue, createIssue, removeLabel, addLabel, commentOnIssue, addBlockedBy, listIssueComments,
|
|
25
|
+
} from '../api/github-rest.mjs';
|
|
26
|
+
import { addSubIssue, listSubIssues } from '../api/github-graphql.mjs';
|
|
5
27
|
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
28
|
import { generateDocument } from '../lib/claude.mjs';
|
|
9
|
-
import { runCritique } from '../lib/critique.mjs';
|
|
29
|
+
import { runCritique, resolveCritiqueAttempt, renderNeedsHumanComment } from '../lib/critique.mjs';
|
|
10
30
|
import { recordUsage } from '../lib/usage-report.mjs';
|
|
11
31
|
import { formatDependencyLine } from '../lib/dependencies.mjs';
|
|
12
32
|
import { lintLanguage } from '../lib/output-lint.mjs';
|
|
13
33
|
import { slugify } from '../lib/slugify.mjs';
|
|
14
34
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
35
|
+
import { loadConfig } from '../lib/project-root.mjs';
|
|
36
|
+
import {
|
|
37
|
+
renderDecompositionDoc, parseDecompositionDoc, DECOMPOSITION_FILE,
|
|
38
|
+
} from '../lib/decomposition-doc.mjs';
|
|
15
39
|
import {
|
|
16
|
-
DECOMPOSE_TARGETS,
|
|
17
|
-
|
|
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,
|
|
18
43
|
} from '../config.mjs';
|
|
19
44
|
|
|
20
45
|
// Adiciona a issue ao board na Etapa ✅ Ready / Status Todo. Best-effort; a
|
|
@@ -24,6 +49,20 @@ async function moveToReady(token, project, etapaField, statusField, nodeId) {
|
|
|
24
49
|
await advanceToStage(token, project, etapaField, statusField, nodeId, STAGE_READY, PROGRESS_TODO);
|
|
25
50
|
}
|
|
26
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
|
+
|
|
27
66
|
// Dobra as barras invertidas que NÃO iniciam um escape válido de JSON. O corpo
|
|
28
67
|
// das tasks costuma trazer trecho de shell/YAML/regex ("\d+", "gradlew \" no fim
|
|
29
68
|
// da linha) e o modelo emite a barra crua: `JSON.parse` morre com "Bad escaped
|
|
@@ -84,6 +123,22 @@ export function parseModelJson(raw) {
|
|
|
84
123
|
// Prefixo de título das sub-issues geradas por cada tipo decompoível.
|
|
85
124
|
const CHILD_PREFIX = { Feature: '[STORY]', RFC: '[TASK]' };
|
|
86
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
|
+
|
|
87
142
|
/**
|
|
88
143
|
* Guard de idempotência do decompose (função PURA — testável).
|
|
89
144
|
*
|
|
@@ -91,22 +146,17 @@ const CHILD_PREFIX = { Feature: '[STORY]', RFC: '[TASK]' };
|
|
|
91
146
|
* sub-issues já contêm um item do tipo-alvo (Feature → algum `[STORY]` no
|
|
92
147
|
* título; RFC → algum `[TASK]`). Sub-issues de outro tipo não contam.
|
|
93
148
|
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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.
|
|
96
151
|
*
|
|
97
152
|
* @param {object} params
|
|
98
153
|
* @param {Array<string|{name: string}>} [params.labels] labels da issue
|
|
99
154
|
* @param {Array<{ number?: number, title?: string }>} [params.subIssues] sub-issues existentes
|
|
100
155
|
* @param {string} params.type tipo da issue ('Feature' | 'RFC')
|
|
101
|
-
* @param {boolean} [params.force] re-executa ignorando os guards
|
|
102
156
|
* @returns {{ skip: boolean, reason: string }}
|
|
103
157
|
*/
|
|
104
|
-
export function shouldSkipDecompose({ labels = [], subIssues = [], type
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const names = labels
|
|
108
|
-
.map(l => (typeof l === 'string' ? l : l?.name))
|
|
109
|
-
.filter(Boolean);
|
|
158
|
+
export function shouldSkipDecompose({ labels = [], subIssues = [], type } = {}) {
|
|
159
|
+
const names = labelNames(labels);
|
|
110
160
|
if (names.includes(LABEL_DECOMPOSED)) {
|
|
111
161
|
return { skip: true, reason: `a issue já tem a label \`${LABEL_DECOMPOSED}\`` };
|
|
112
162
|
}
|
|
@@ -123,101 +173,12 @@ export function shouldSkipDecompose({ labels = [], subIssues = [], type, force =
|
|
|
123
173
|
return { skip: false, reason: '' };
|
|
124
174
|
}
|
|
125
175
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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(() => {});
|
|
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) };
|
|
221
182
|
}
|
|
222
183
|
|
|
223
184
|
// Lint de idioma sobre títulos+corpos gerados; retorna aviso pronto para
|
|
@@ -232,6 +193,20 @@ function formatItemsLintWarning(texts) {
|
|
|
232
193
|
return `\n\n⚠️ possíveis artefatos de idioma nos itens gerados: ${excerpts}`;
|
|
233
194
|
}
|
|
234
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
|
+
|
|
235
210
|
const FEATURE_SYSTEM_PROMPT = `Você é um Tech Lead experiente em decomposição de trabalho ágil.
|
|
236
211
|
A partir da Feature fornecida (com spec.md e plan.md), gere uma lista de Stories e Tasks.
|
|
237
212
|
|
|
@@ -280,69 +255,239 @@ Regras:
|
|
|
280
255
|
- "body" detalhado e acionável.
|
|
281
256
|
- Gere entre 3 e 10 Tasks concretas que, juntas, cubram o RFC.`;
|
|
282
257
|
|
|
283
|
-
//
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
//
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
const
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
+
);
|
|
300
355
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const decomposition = parseModelJson(await generateDocument(FEATURE_SYSTEM_PROMPT, userContent, { action: 'decompose', usage }));
|
|
309
|
-
|
|
310
|
-
// Crítica adversarial ANTES de criar qualquer issue: stories que contradizem
|
|
311
|
-
// a spec/plan não devem virar trabalho. Crítica indisponível → só avisa.
|
|
312
|
-
let critique = null;
|
|
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;
|
|
313
362
|
try {
|
|
314
363
|
critique = await runCritique({
|
|
315
364
|
kind: 'stories',
|
|
316
|
-
spec:
|
|
317
|
-
plan:
|
|
318
|
-
|
|
365
|
+
spec: specContent,
|
|
366
|
+
plan: planContent,
|
|
367
|
+
decomposition: markdown,
|
|
368
|
+
attempt,
|
|
369
|
+
maxAttempts: ctx.maxCritiqueAttempts,
|
|
370
|
+
model,
|
|
371
|
+
labels,
|
|
319
372
|
usage,
|
|
320
373
|
});
|
|
321
374
|
} catch (err) {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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.`
|
|
326
384
|
).catch(() => {});
|
|
385
|
+
await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE).catch(() => {});
|
|
386
|
+
throw new DecomposeBlockedError(`Crítica adversarial não concluiu: ${err.message}`);
|
|
327
387
|
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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.`);
|
|
337
444
|
}
|
|
338
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
|
+
);
|
|
467
|
+
}
|
|
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;
|
|
339
484
|
const featureNodeId = issue.node_id;
|
|
340
485
|
const created = [];
|
|
341
486
|
const createdStories = []; // issues criadas, na ordem dos índices das stories
|
|
342
487
|
const generatedTexts = []; // títulos+corpos para o lint de idioma final
|
|
343
488
|
|
|
344
|
-
for (let i = 0; i <
|
|
345
|
-
const story =
|
|
489
|
+
for (let i = 0; i < doc.stories.length; i++) {
|
|
490
|
+
const story = doc.stories[i];
|
|
346
491
|
console.log(`Criando story: ${story.title}`);
|
|
347
492
|
const storyTitle = `[STORY] ${story.title}`;
|
|
348
493
|
let storyBody = [story.userStory, story.body]
|
|
@@ -350,13 +495,8 @@ async function decomposeFeature(ctx) {
|
|
|
350
495
|
.filter(Boolean)
|
|
351
496
|
.join('\n\n') || '_(sem descrição)_';
|
|
352
497
|
|
|
353
|
-
//
|
|
354
|
-
|
|
355
|
-
// sem dependências. Índices inválidos/futuros são ignorados.
|
|
356
|
-
const depIndexes = Array.isArray(story.dependsOn)
|
|
357
|
-
? [...new Set(story.dependsOn.filter(d => Number.isInteger(d) && d >= 0 && d < i))]
|
|
358
|
-
: (i > 0 ? [i - 1] : []);
|
|
359
|
-
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);
|
|
360
500
|
const depLine = formatDependencyLine(depIssues.map(d => d.number));
|
|
361
501
|
if (depLine) storyBody += `\n\n${depLine}`;
|
|
362
502
|
|
|
@@ -411,43 +551,23 @@ async function decomposeFeature(ctx) {
|
|
|
411
551
|
console.warn(`Falha ao mover Feature para "${STAGE_READY}": ${err.message}`);
|
|
412
552
|
}
|
|
413
553
|
|
|
414
|
-
// Marca a Feature como decomposta (guard de idempotência em runs futuros).
|
|
415
|
-
try {
|
|
416
|
-
await addLabel(token, owner, repo, parseInt(issueNumber, 10), LABEL_DECOMPOSED);
|
|
417
|
-
} catch (err) {
|
|
418
|
-
console.warn(`Falha ao aplicar a label ${LABEL_DECOMPOSED}: ${err.message}`);
|
|
419
|
-
}
|
|
420
|
-
await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
|
|
421
554
|
const list = created.map(s => `- ${s.url} — ${s.title}`).join('\n');
|
|
422
|
-
await commentOnIssue(
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
`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` +
|
|
426
558
|
`Tudo posicionado em **✅ Ready**. Inicie o desenvolvimento com \`npx @spec-wave/cli@latest implement ${issueNumber}\` (Stories em ordem de dependência).` +
|
|
427
559
|
formatItemsLintWarning(generatedTexts)
|
|
428
560
|
);
|
|
429
|
-
console.log(`Decomposição
|
|
561
|
+
console.log(`Decomposição aplicada: ${created.length} stories criadas.`);
|
|
430
562
|
}
|
|
431
563
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
const { token, projectToken, owner, repo, issue, issueNumber, project, etapaField, statusField, usage } = ctx;
|
|
436
|
-
console.log(`Decompondo RFC: ${issue.title}`);
|
|
437
|
-
|
|
438
|
-
const userContent = [
|
|
439
|
-
`RFC: ${issue.title}`,
|
|
440
|
-
`Issue #${issueNumber}`,
|
|
441
|
-
`\n## Descrição\n${issue.body || '(sem descrição)'}`,
|
|
442
|
-
].join('\n');
|
|
443
|
-
|
|
444
|
-
const decomposition = parseModelJson(await generateDocument(RFC_SYSTEM_PROMPT, userContent, { action: 'decompose', usage }));
|
|
445
|
-
const rfcNodeId = issue.node_id;
|
|
446
|
-
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;
|
|
447
567
|
const created = [];
|
|
448
|
-
const generatedTexts = [];
|
|
568
|
+
const generatedTexts = [];
|
|
449
569
|
|
|
450
|
-
for (const task of tasks) {
|
|
570
|
+
for (const task of doc.tasks) {
|
|
451
571
|
console.log(`Criando task: ${task.title}`);
|
|
452
572
|
const taskTitle = `[TASK] ${task.title}`;
|
|
453
573
|
const taskBody = `${task.body}\n\n_RFC pai: ${issue.html_url || `#${issueNumber}`}_`;
|
|
@@ -456,7 +576,7 @@ async function decomposeRFC(ctx) {
|
|
|
456
576
|
generatedTexts.push(taskTitle, taskBody);
|
|
457
577
|
|
|
458
578
|
try {
|
|
459
|
-
await addSubIssue(token,
|
|
579
|
+
await addSubIssue(token, parentNodeId, createdTask.nodeId);
|
|
460
580
|
} catch (err) {
|
|
461
581
|
console.warn(` Task #${createdTask.number} criada, mas falhou ao vincular ao RFC: ${err.message}`);
|
|
462
582
|
}
|
|
@@ -467,25 +587,42 @@ async function decomposeRFC(ctx) {
|
|
|
467
587
|
}
|
|
468
588
|
}
|
|
469
589
|
|
|
470
|
-
// Marca o RFC como decomposto (guard de idempotência em runs futuros).
|
|
471
|
-
try {
|
|
472
|
-
await addLabel(token, owner, repo, parseInt(issueNumber, 10), LABEL_DECOMPOSED);
|
|
473
|
-
} catch (err) {
|
|
474
|
-
console.warn(`Falha ao aplicar a label ${LABEL_DECOMPOSED}: ${err.message}`);
|
|
475
|
-
}
|
|
476
|
-
await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
|
|
477
590
|
const list = created.map(t => `- ${t.url} — ${t.title}`).join('\n');
|
|
478
|
-
await commentOnIssue(
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
`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` +
|
|
482
594
|
`Tudo posicionado em **✅ Ready**. Inicie o desenvolvimento com \`npx @spec-wave/cli@latest implement <task>\`.` +
|
|
483
595
|
formatItemsLintWarning(generatedTexts)
|
|
484
596
|
);
|
|
485
|
-
console.log(`Decomposição
|
|
597
|
+
console.log(`Decomposição aplicada: ${created.length} tasks criadas.`);
|
|
486
598
|
}
|
|
487
599
|
|
|
488
|
-
|
|
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 };
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ---------------------------------------------------------------------------
|
|
622
|
+
// Entrada
|
|
623
|
+
// ---------------------------------------------------------------------------
|
|
624
|
+
|
|
625
|
+
export async function decompose({ issueNumber, apply = false }) {
|
|
489
626
|
const token = await resolveToken();
|
|
490
627
|
// PROJECT_TOKEN deve ter scope "project" para atualizar GitHub Projects v2.
|
|
491
628
|
// Fallback para GITHUB_TOKEN (só funciona em repos pessoais sem org restrictions).
|
|
@@ -500,21 +637,36 @@ export async function decompose({ issueNumber, force = false }) {
|
|
|
500
637
|
);
|
|
501
638
|
}
|
|
502
639
|
|
|
503
|
-
const
|
|
640
|
+
const number = parseInt(issueNumber, 10);
|
|
641
|
+
const issue = await getIssue(token, owner, repo, number);
|
|
504
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;
|
|
505
646
|
|
|
506
647
|
// Só Feature (→ Stories) e RFC (→ Tasks) podem ser decompostos.
|
|
507
648
|
if (!DECOMPOSE_TARGETS[type]) {
|
|
508
649
|
console.log(`Issue #${issueNumber} é ${type || 'desconhecido'} — decompose não se aplica.`);
|
|
509
|
-
await removeLabel(token, owner, repo,
|
|
510
|
-
await commentOnIssue(
|
|
511
|
-
token, owner, repo, parseInt(issueNumber, 10),
|
|
650
|
+
await removeLabel(token, owner, repo, number, trigger);
|
|
651
|
+
await commentOnIssue(token, owner, repo, number,
|
|
512
652
|
`ℹ️ **decompose não se aplica a ${type || 'este tipo'}.** ` +
|
|
513
653
|
`Use em **Features** (gera Stories + Tasks) ou **RFCs** (gera Tasks).`
|
|
514
654
|
).catch(() => {});
|
|
515
655
|
return;
|
|
516
656
|
}
|
|
517
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
|
+
|
|
518
670
|
// Guard de idempotência: label spec-wave:decomposed ou sub-issues do
|
|
519
671
|
// tipo-alvo já existentes → não re-decompõe (evita duplicar stories/tasks).
|
|
520
672
|
let subIssues = [];
|
|
@@ -524,56 +676,63 @@ export async function decompose({ issueNumber, force = false }) {
|
|
|
524
676
|
console.warn(`Não foi possível listar sub-issues: ${err.message} — seguindo sem o guard de sub-issues.`);
|
|
525
677
|
subIssues = [];
|
|
526
678
|
}
|
|
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 });
|
|
679
|
+
const guard = shouldSkipDecompose({ labels, subIssues, type });
|
|
531
680
|
if (guard.skip) {
|
|
532
681
|
console.log(`Decompose ignorado: ${guard.reason}.`);
|
|
533
|
-
await removeLabel(token, owner, repo,
|
|
534
|
-
await commentOnIssue(
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
`Para re-decompor, adicione a label \`${LABEL_FORCE}\` junto com \`spec-wave:decompose\` — ` +
|
|
538
|
-
'as sub-issues da decomposição anterior serão **fechadas** e novas serão geradas.'
|
|
682
|
+
await removeLabel(token, owner, repo, number, trigger);
|
|
683
|
+
await commentOnIssue(token, owner, repo, number,
|
|
684
|
+
`⏭️ **decompose ignorado:** ${guard.reason}. Para forçar, remova a label ` +
|
|
685
|
+
`\`${LABEL_DECOMPOSED}\` (e apague as sub-issues antigas se quiser re-gerar).`
|
|
539
686
|
).catch(() => {});
|
|
540
687
|
return;
|
|
541
688
|
}
|
|
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
689
|
|
|
547
|
-
//
|
|
548
|
-
const {
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
} catch (err) {
|
|
561
|
-
console.warn(`Não foi possível resolver campo Status do board: ${err.message}`);
|
|
562
|
-
}
|
|
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
|
+
});
|
|
563
703
|
}
|
|
564
704
|
|
|
565
705
|
// Coletor de uso de IA — o finally registra o custo já incorrido mesmo nos
|
|
566
|
-
// fluxos que
|
|
706
|
+
// fluxos que abortam (ex.: crítica grave) ou que falham.
|
|
567
707
|
const usageEntries = [];
|
|
568
|
-
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
|
+
|
|
569
719
|
try {
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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;
|
|
575
734
|
} finally {
|
|
576
735
|
// Best-effort: nunca propaga erro (ver recordUsage).
|
|
577
|
-
await recordUsage({ token, owner, repo, issueNumber:
|
|
736
|
+
await recordUsage({ token, owner, repo, issueNumber: number, entries: usageEntries });
|
|
578
737
|
}
|
|
579
738
|
}
|