@spec-wave/cli 0.15.0 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/bin/spec-wave.mjs +44 -5
- package/package.json +8 -2
- package/src/agent/anthropic-agent.mjs +337 -0
- package/src/agent/errors.mjs +33 -0
- package/src/agent/index.mjs +108 -0
- package/src/agent/openrouter-agent.mjs +378 -0
- package/src/agent/run-types.mjs +59 -0
- package/src/agent/telemetry.mjs +54 -0
- package/src/agent/tools.mjs +452 -0
- package/src/agent/tracing.mjs +106 -0
- package/src/api/github-graphql.mjs +23 -1
- package/src/api/github-rest.mjs +8 -0
- package/src/commands/bug.mjs +8 -0
- package/src/commands/code-review.mjs +45 -4
- package/src/commands/decompose.mjs +22 -72
- package/src/commands/dev-agent.mjs +3 -3
- package/src/commands/doctor.mjs +77 -6
- package/src/commands/generate-bug.mjs +195 -0
- package/src/commands/generate-plan.mjs +19 -44
- package/src/commands/generate-spec.mjs +18 -46
- package/src/commands/implement.mjs +105 -2
- package/src/commands/init.mjs +3 -3
- package/src/commands/install-skill.mjs +72 -16
- package/src/commands/issue.mjs +9 -7
- package/src/commands/move.mjs +11 -1
- package/src/commands/qa.mjs +23 -2
- package/src/commands/refresh.mjs +171 -5
- package/src/commands/triage.mjs +174 -0
- package/src/commands/update.mjs +16 -3
- package/src/commands/validate.mjs +82 -10
- package/src/config.mjs +159 -1
- package/src/lib/bug-context.mjs +160 -0
- package/src/lib/bug-doc.mjs +51 -0
- package/src/lib/bug-triage.mjs +81 -0
- package/src/lib/claude.mjs +71 -254
- package/src/lib/critique.mjs +43 -30
- package/src/lib/flow-run.mjs +145 -0
- package/src/lib/implement-board.mjs +12 -1
- package/src/lib/plugin-skills.mjs +122 -0
- package/src/lib/project-root.mjs +9 -2
- package/src/lib/prompt-loader.mjs +257 -0
- package/src/lib/skill-file.mjs +35 -0
- package/src/plugin/.claude-plugin/plugin.json +20 -0
- package/src/plugin/README.md +73 -0
- package/src/plugin/skills/bug/SKILL.md +60 -0
- package/src/plugin/skills/bug/model-prompt.critique.md +48 -0
- package/src/plugin/skills/bug/model-prompt.md +74 -0
- package/src/plugin/skills/decompose/SKILL.md +117 -0
- package/src/plugin/skills/decompose/model-prompt.critique.md +46 -0
- package/src/plugin/skills/decompose/model-prompt.feature.md +69 -0
- package/src/plugin/skills/decompose/model-prompt.rfc.md +52 -0
- package/src/plugin/skills/doctor/SKILL.md +51 -0
- package/src/plugin/skills/fix-pr/SKILL.md +130 -0
- package/src/plugin/skills/implement/SKILL.md +102 -0
- package/src/plugin/skills/info/SKILL.md +40 -0
- package/src/plugin/skills/issue/SKILL.md +63 -0
- package/src/plugin/skills/move/SKILL.md +52 -0
- package/src/plugin/skills/order/SKILL.md +36 -0
- package/src/plugin/skills/plan/SKILL.md +58 -0
- package/src/plugin/skills/plan/model-prompt.critique.md +44 -0
- package/src/plugin/skills/plan/model-prompt.md +59 -0
- package/src/plugin/skills/plan/reference/tech-context.md +56 -0
- package/src/plugin/skills/ready/SKILL.md +44 -0
- package/src/plugin/skills/rfc/SKILL.md +47 -0
- package/src/plugin/skills/setup/SKILL.md +67 -0
- package/src/plugin/skills/spec/SKILL.md +55 -0
- package/src/plugin/skills/spec/model-prompt.md +61 -0
- package/src/plugin/skills/story/SKILL.md +49 -0
- package/src/plugin/skills/task/SKILL.md +41 -0
- package/src/plugin/skills/triage/SKILL.md +52 -0
- package/src/plugin/skills/uninstall/SKILL.md +43 -0
- package/src/plugin/skills/update/SKILL.md +51 -0
- package/src/plugin/skills/workflow/SKILL.md +158 -0
- package/src/templates/skill/SKILL.md +54 -4
- package/src/templates/workflows/generate-bug.yml +36 -0
- package/src/templates/workflows/validate.yml +2 -1
- package/src/ui/wizard.mjs +5 -2
|
@@ -52,16 +52,40 @@ async function resolveFeatureIssue(token, owner, repo, issueNumber) {
|
|
|
52
52
|
// tipo porque cada um tem destino próprio: Tasks → Done, Stories → Code Review,
|
|
53
53
|
// Feature → Code Review (só quando todas as Stories estiverem prontas). Retorna
|
|
54
54
|
// { feature:{number,nodeId,title}|null, stories:Map, tasks:Map }.
|
|
55
|
+
/**
|
|
56
|
+
* Como o PR trata uma issue referenciada, pelo tipo (função PURA).
|
|
57
|
+
*
|
|
58
|
+
* 'bug' → unidade de review PRÓPRIA: move o Bug e NÃO toca na Feature-pai.
|
|
59
|
+
* Um defeito em review não deve arrastar a Feature inteira para
|
|
60
|
+
* Code Review — ela pode ter Stories ainda em desenvolvimento.
|
|
61
|
+
* 'unit' → Feature/Story/Task: sobe pela cadeia até a Feature (fluxo atual).
|
|
62
|
+
* 'ignore' → Spike, RFC, Epic e desconhecidos.
|
|
63
|
+
*
|
|
64
|
+
* @param {string|null} type
|
|
65
|
+
* @returns {'bug'|'unit'|'ignore'}
|
|
66
|
+
*/
|
|
67
|
+
export function classifyReviewTarget(type) {
|
|
68
|
+
if (type === 'Bug') return 'bug';
|
|
69
|
+
if (type === 'Feature' || type === 'Story' || type === 'Task') return 'unit';
|
|
70
|
+
return 'ignore';
|
|
71
|
+
}
|
|
72
|
+
|
|
55
73
|
async function collectReviewUnit(token, owner, repo, issueNumber) {
|
|
56
74
|
const stories = new Map();
|
|
57
75
|
const tasks = new Map();
|
|
76
|
+
const bugs = new Map();
|
|
58
77
|
const addStory = (n, nodeId, title) => { if (n && nodeId && !stories.has(n)) stories.set(n, { nodeId, title }); };
|
|
59
78
|
const addTask = (n, nodeId, title) => { if (n && nodeId && !tasks.has(n)) tasks.set(n, { nodeId, title }); };
|
|
60
79
|
|
|
61
80
|
let issue;
|
|
62
|
-
try { issue = await getIssue(token, owner, repo, issueNumber); } catch { return { feature: null, stories, tasks }; }
|
|
81
|
+
try { issue = await getIssue(token, owner, repo, issueNumber); } catch { return { feature: null, stories, tasks, bugs }; }
|
|
63
82
|
const type = detectIssueType(issue);
|
|
64
|
-
|
|
83
|
+
const kind = classifyReviewTarget(type);
|
|
84
|
+
if (kind === 'ignore') return { feature: null, stories, tasks, bugs };
|
|
85
|
+
if (kind === 'bug') {
|
|
86
|
+
bugs.set(issue.number, { nodeId: issue.node_id, title: issue.title });
|
|
87
|
+
return { feature: null, stories, tasks, bugs };
|
|
88
|
+
}
|
|
65
89
|
|
|
66
90
|
const featureIssue = await resolveFeatureIssue(token, owner, repo, issueNumber);
|
|
67
91
|
const feature = featureIssue
|
|
@@ -93,7 +117,7 @@ async function collectReviewUnit(token, owner, repo, issueNumber) {
|
|
|
93
117
|
for (const t of tks) { if (isManual(t)) continue; addTask(t.number, t.nodeId, t.title); }
|
|
94
118
|
}
|
|
95
119
|
}
|
|
96
|
-
return { feature, stories, tasks };
|
|
120
|
+
return { feature, stories, tasks, bugs };
|
|
97
121
|
}
|
|
98
122
|
|
|
99
123
|
// A Feature só avança quando TODAS as suas Stories já estiverem em Code Review
|
|
@@ -155,7 +179,7 @@ export async function codeReview({ prNumber }) {
|
|
|
155
179
|
// Stories → 👀 Code Review; Feature → Code Review só quando TODAS as suas
|
|
156
180
|
// Stories já estiverem em Code Review.
|
|
157
181
|
for (const num of issueNums) {
|
|
158
|
-
const { feature, stories, tasks } = await collectReviewUnit(token, owner, repo, num);
|
|
182
|
+
const { feature, stories, tasks, bugs } = await collectReviewUnit(token, owner, repo, num);
|
|
159
183
|
|
|
160
184
|
// Tasks → Done (Status Done).
|
|
161
185
|
for (const [n, info] of tasks) {
|
|
@@ -191,6 +215,23 @@ export async function codeReview({ prNumber }) {
|
|
|
191
215
|
}
|
|
192
216
|
}
|
|
193
217
|
|
|
218
|
+
// Bugs → Code Review, sem Feature-pai envolvida.
|
|
219
|
+
for (const [n, info] of bugs) {
|
|
220
|
+
if (seen.has(n)) continue;
|
|
221
|
+
seen.add(n);
|
|
222
|
+
try {
|
|
223
|
+
const moved = await advanceToStage(projectToken, project, etapaField, statusField, info.nodeId, CODE_REVIEW_STAGE, TODO_STATUS);
|
|
224
|
+
if (moved) {
|
|
225
|
+
updated.push(`#${n} ${info.title} → ${CODE_REVIEW_STAGE}`);
|
|
226
|
+
console.log(`Bug #${n} → "${CODE_REVIEW_STAGE}" / Status "${TODO_STATUS}".`);
|
|
227
|
+
} else {
|
|
228
|
+
console.log(`Bug #${n} já está em "${CODE_REVIEW_STAGE}" ou etapa posterior — mantido (não retrocede).`);
|
|
229
|
+
}
|
|
230
|
+
} catch (err) {
|
|
231
|
+
console.warn(`Falha ao atualizar #${n}: ${err.message}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
194
235
|
// Feature: só avança se todas as suas Stories já estão em Code Review+.
|
|
195
236
|
if (feature && !featuresChecked.has(feature.number)) {
|
|
196
237
|
featuresChecked.add(feature.number);
|
|
@@ -33,6 +33,8 @@ import { lintLanguage } from '../lib/output-lint.mjs';
|
|
|
33
33
|
import { slugify } from '../lib/slugify.mjs';
|
|
34
34
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
35
35
|
import { loadConfig } from '../lib/project-root.mjs';
|
|
36
|
+
import { resolveFlowContext, commitGenerated } from '../lib/flow-run.mjs';
|
|
37
|
+
import { loadPrompt, toolFreeSystemPrompt } from '../lib/prompt-loader.mjs';
|
|
36
38
|
import {
|
|
37
39
|
renderDecompositionDoc, parseDecompositionDoc, DECOMPOSITION_FILE,
|
|
38
40
|
} from '../lib/decomposition-doc.mjs';
|
|
@@ -193,74 +195,24 @@ function formatItemsLintWarning(texts) {
|
|
|
193
195
|
return `\n\n⚠️ possíveis artefatos de idioma nos itens gerados: ${excerpts}`;
|
|
194
196
|
}
|
|
195
197
|
|
|
196
|
-
// Grava e commita o rascunho
|
|
197
|
-
// com
|
|
198
|
-
function commitFile(filePath, content, message) {
|
|
199
|
-
|
|
200
|
-
|
|
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');
|
|
198
|
+
// Grava e commita o rascunho. O modo (actions|local) decide identidade do git e
|
|
199
|
+
// o que fazer com falha de push — ver `lib/flow-run.mjs`.
|
|
200
|
+
function commitFile(filePath, content, message, mode) {
|
|
201
|
+
const published = commitGenerated({ filePath, content, message, mode });
|
|
202
|
+
if (published.warning) console.warn(`⚠️ ${published.warning}`);
|
|
208
203
|
}
|
|
209
204
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
{
|
|
215
|
-
"stories": [
|
|
216
|
-
{
|
|
217
|
-
"title": "Título curto da story (apenas a parte 'quero', sem prefixo)",
|
|
218
|
-
"userStory": "Como <perfil>, quero <objetivo>, para <benefício>",
|
|
219
|
-
"body": "Descrição complementar da story com contexto e critérios de aceite relevantes",
|
|
220
|
-
"dependsOn": [0],
|
|
221
|
-
"tasks": [
|
|
222
|
-
{
|
|
223
|
-
"title": "Título técnico curto da task (sem prefixo)",
|
|
224
|
-
"body": "Descrição técnica detalhada"
|
|
225
|
-
}
|
|
226
|
-
]
|
|
227
|
-
}
|
|
228
|
-
]
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
Regras:
|
|
232
|
-
- "title" deve ser CURTO (máx. ~60 caracteres): apenas a parte "quero" da user story, sem o "Como" nem o "para", e sem prefixo. Ex.: "visualizar meus repositórios em layout responsivo"
|
|
233
|
-
- "userStory" deve trazer a user story completa no formato "Como <perfil>, quero <objetivo>, para <benefício>"
|
|
234
|
-
- "body" é texto complementar (contexto, critérios de aceite); não repita o título
|
|
235
|
-
- Cada Story deve ter 2–5 Tasks associadas
|
|
236
|
-
- Tasks devem ser atividades técnicas concretas, com "title" curto e "body" detalhado
|
|
237
|
-
- Gere entre 3 e 7 Stories por Feature
|
|
238
|
-
- Ordene as stories na sequência de implementação — a ORDEM da lista importa
|
|
239
|
-
- "dependsOn" (opcional): índices 0-based das stories ANTERIORES na lista das quais esta story depende. Referencie apenas índices menores que o da própria story. Use [] quando a story puder ser feita em paralelo (sem dependências); se omitido, assume-se dependência da story anterior (sequencial)`;
|
|
240
|
-
|
|
241
|
-
const RFC_SYSTEM_PROMPT = `Você é um Tech Lead experiente. A partir do RFC fornecido (proposta técnica/de processo), gere a lista de Tasks técnicas concretas necessárias para implementá-lo.
|
|
242
|
-
|
|
243
|
-
Responda APENAS com JSON válido neste formato:
|
|
244
|
-
{
|
|
245
|
-
"tasks": [
|
|
246
|
-
{
|
|
247
|
-
"title": "Título técnico curto da task (sem prefixo)",
|
|
248
|
-
"body": "Descrição técnica detalhada (o que fazer, áreas/arquivos afetados, critério de pronto)"
|
|
249
|
-
}
|
|
250
|
-
]
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
Regras:
|
|
254
|
-
- "title" CURTO (máx. ~60 caracteres), sem prefixo.
|
|
255
|
-
- "body" detalhado e acionável.
|
|
256
|
-
- Gere entre 3 e 10 Tasks concretas que, juntas, cubram o RFC.`;
|
|
205
|
+
// Os prompts vivem em `src/plugin/skills/decompose/model-prompt.{feature,rfc}.md`
|
|
206
|
+
// (sobrescrevíveis em `.spec-wave/prompts/decompose.feature.md`). O contrato JSON é o
|
|
207
|
+
// `json.shape` do frontmatter — quem parseia a saída e quem instrui o modelo
|
|
208
|
+
// passam a ler a MESMA declaração, em vez de duas cópias que podiam divergir.
|
|
257
209
|
|
|
258
210
|
// ---------------------------------------------------------------------------
|
|
259
211
|
// Etapa 1: rascunho (label spec-wave:decompose)
|
|
260
212
|
// ---------------------------------------------------------------------------
|
|
261
213
|
|
|
262
214
|
async function draftDecomposition(ctx) {
|
|
263
|
-
const { token, owner, repo, issue, issueNumber, type, labels, usage, docDir, docPath, docRel } = ctx;
|
|
215
|
+
const { token, owner, repo, issue, issueNumber, type, labels, usage, root, runMode, docDir, docPath, docRel } = ctx;
|
|
264
216
|
const number = parseInt(issueNumber, 10);
|
|
265
217
|
const kind = DECOMPOSE_TARGETS[type]; // Feature → 'stories'; RFC → 'tasks'
|
|
266
218
|
const blobUrl = `https://github.com/${owner}/${repo}/blob/main/${docRel}`;
|
|
@@ -292,8 +244,12 @@ async function draftDecomposition(ctx) {
|
|
|
292
244
|
`\n## plan.md\n${planContent || '(plan.md não encontrado)'}`,
|
|
293
245
|
].join('\n');
|
|
294
246
|
|
|
247
|
+
const decomposePrompt = loadPrompt(
|
|
248
|
+
type === 'RFC' ? 'decompose/rfc' : 'decompose/feature',
|
|
249
|
+
{ cwd: root },
|
|
250
|
+
);
|
|
295
251
|
const generated = parseModelJson(await generateDocument(
|
|
296
|
-
|
|
252
|
+
toolFreeSystemPrompt(decomposePrompt),
|
|
297
253
|
userContent,
|
|
298
254
|
{ action: 'decompose', labels, usage }
|
|
299
255
|
));
|
|
@@ -305,7 +261,7 @@ async function draftDecomposition(ctx) {
|
|
|
305
261
|
stories: generated.stories || [],
|
|
306
262
|
tasks: generated.tasks || [],
|
|
307
263
|
});
|
|
308
|
-
commitFile(docPath, markdown, `docs: rascunho de decomposição de ${docRel} [spec-wave]
|
|
264
|
+
commitFile(docPath, markdown, `docs: rascunho de decomposição de ${docRel} [spec-wave]`, runMode);
|
|
309
265
|
console.log(`Rascunho commitado em ${docRel}.`);
|
|
310
266
|
}
|
|
311
267
|
|
|
@@ -627,15 +583,9 @@ export async function decompose({ issueNumber, apply = false }) {
|
|
|
627
583
|
// PROJECT_TOKEN deve ter scope "project" para atualizar GitHub Projects v2.
|
|
628
584
|
// Fallback para GITHUB_TOKEN (só funciona em repos pessoais sem org restrictions).
|
|
629
585
|
const projectToken = process.env.PROJECT_TOKEN || token;
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
throw new Error(
|
|
634
|
-
'GITHUB_REPOSITORY env var não definida.\n' +
|
|
635
|
-
'Este comando roda no GitHub Actions. Para testar localmente:\n' +
|
|
636
|
-
' GITHUB_REPOSITORY=owner/repo spec-wave decompose --issue-number 1'
|
|
637
|
-
);
|
|
638
|
-
}
|
|
586
|
+
// Roda nos dois modos: no Action (disparado por label) e na sessão local.
|
|
587
|
+
const { owner, repo, mode: runMode } = resolveFlowContext({ command: 'decompose' });
|
|
588
|
+
console.log(`Modo de execução: ${runMode}`);
|
|
639
589
|
|
|
640
590
|
const number = parseInt(issueNumber, 10);
|
|
641
591
|
const issue = await getIssue(token, owner, repo, number);
|
|
@@ -707,7 +657,7 @@ export async function decompose({ issueNumber, apply = false }) {
|
|
|
707
657
|
const usageEntries = [];
|
|
708
658
|
const ctx = {
|
|
709
659
|
token, projectToken, owner, repo, issue, issueNumber, type, labels, comments,
|
|
710
|
-
root, docDir, docPath, docRel: `${docRel}/${DECOMPOSITION_FILE}`,
|
|
660
|
+
root, runMode, docDir, docPath, docRel: `${docRel}/${DECOMPOSITION_FILE}`,
|
|
711
661
|
escalationModel: config?.ai?.escalationModel || null,
|
|
712
662
|
maxCritiqueAttempts:
|
|
713
663
|
Number.isInteger(config?.ai?.maxCritiqueAttempts) && config.ai.maxCritiqueAttempts > 0
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
import { homedir, tmpdir } from 'node:os';
|
|
20
20
|
import path from 'node:path';
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
22
|
-
import { CONFIG_FILE } from '../config.mjs';
|
|
22
|
+
import { CONFIG_FILE, LABEL_DEV_AGENT } from '../config.mjs';
|
|
23
23
|
import { findConfigPath } from '../lib/project-root.mjs';
|
|
24
24
|
|
|
25
25
|
const __dir = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -82,7 +82,7 @@ export function renderAgentConfig({ owner, repo }) {
|
|
|
82
82
|
# Schema completo: https://github.com/${AGENT_REPO}#configuração
|
|
83
83
|
|
|
84
84
|
repo = "${owner}/${repo}"
|
|
85
|
-
queue_label = "
|
|
85
|
+
queue_label = "${LABEL_DEV_AGENT}"
|
|
86
86
|
|
|
87
87
|
# Defaults do agente (descomente para ajustar):
|
|
88
88
|
#poll_interval_secs = 60 # consulta à fila quando ocioso
|
|
@@ -412,7 +412,7 @@ async function finishSetup({ cfg, home, binPath, configPath, options, configActi
|
|
|
412
412
|
p.outro(
|
|
413
413
|
`${chalk.green('✓')} Agente pronto.\n` +
|
|
414
414
|
` Rodar agora: ${chalk.cyan('spec-wave dev-agent --run')}\n` +
|
|
415
|
-
|
|
415
|
+
` Enfileirar: aplique a label ${LABEL_DEV_AGENT} numa issue [FEATURE] já decomposta.`
|
|
416
416
|
);
|
|
417
417
|
}
|
|
418
418
|
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -12,7 +12,8 @@ import { resolveToken, verifyTokenScopes } from '../api/auth.mjs';
|
|
|
12
12
|
import { getProjectSnapshot } from '../api/github-graphql.mjs';
|
|
13
13
|
import {
|
|
14
14
|
CONFIG_FILE, WORKFLOW_FILES, getProvider, DEFAULT_PROVIDER, STATUS_OPTIONS,
|
|
15
|
-
ALL_LABELS, LABEL_NEEDS_HUMAN, MODEL_LABEL_PREFIX,
|
|
15
|
+
RETIRED_STAGES, ALL_LABELS, LABEL_NEEDS_HUMAN, MODEL_LABEL_PREFIX,
|
|
16
|
+
DEFAULT_MAX_CRITIQUE_ATTEMPTS, STAGE_TRACKS,
|
|
16
17
|
} from '../config.mjs';
|
|
17
18
|
import { findConfigPath } from '../lib/project-root.mjs';
|
|
18
19
|
import {
|
|
@@ -284,7 +285,12 @@ async function checkConfig(ctx) {
|
|
|
284
285
|
export function inspectBoardHygiene({ boardStages = null, repoLabels = null } = {}) {
|
|
285
286
|
const canonical = STATUS_OPTIONS.map(s => s.name);
|
|
286
287
|
const known = new Set(canonical);
|
|
287
|
-
const
|
|
288
|
+
const retired = new Map(RETIRED_STAGES.map(s => [s.name, s]));
|
|
289
|
+
const foreign = boardStages ? boardStages.filter(s => !known.has(s)) : [];
|
|
290
|
+
// Uma etapa aposentada também é "fora do fluxo", mas a orientação ao usuário
|
|
291
|
+
// é diferente da de uma coluna inventada — por isso saem em listas separadas.
|
|
292
|
+
const retiredStages = foreign.filter(s => retired.has(s)).map(s => retired.get(s));
|
|
293
|
+
const unknownStages = foreign.filter(s => !retired.has(s));
|
|
288
294
|
const missingStages = boardStages ? canonical.filter(s => !boardStages.includes(s)) : [];
|
|
289
295
|
|
|
290
296
|
const knownLabels = new Set(ALL_LABELS.map(l => l.name));
|
|
@@ -294,7 +300,29 @@ export function inspectBoardHygiene({ boardStages = null, repoLabels = null } =
|
|
|
294
300
|
const missingLabels = repoLabels
|
|
295
301
|
? ALL_LABELS.map(l => l.name).filter(n => !repoLabels.includes(n))
|
|
296
302
|
: [];
|
|
297
|
-
return { unknownStages, missingStages, orphanLabels, missingLabels };
|
|
303
|
+
return { unknownStages, retiredStages, missingStages, orphanLabels, missingLabels };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* O board comporta a trilha do Bug? (função PURA)
|
|
308
|
+
*
|
|
309
|
+
* Um Bug percorre 🐞 Triagem → ✅ Ready → … → 🚀 Deploy → 🎉 Done (RFC-004 §4).
|
|
310
|
+
* Se o board não tem alguma dessas colunas, o item não tem onde parar: o campo
|
|
311
|
+
* "Etapa" só aceita as opções que existem, então a escrita falha em silêncio e
|
|
312
|
+
* o bug fica sem etapa — invisível em toda tela que filtra por etapa.
|
|
313
|
+
*
|
|
314
|
+
* Só reporta quando há Bug aberto: um board sem bugs não precisa da trilha.
|
|
315
|
+
*
|
|
316
|
+
* @param {object} params
|
|
317
|
+
* @param {string[]|null} [params.boardStages] opções do campo Etapa
|
|
318
|
+
* @param {number} [params.openBugCount] bugs abertos no repositório
|
|
319
|
+
* @returns {{ missingTrackStages: string[] }}
|
|
320
|
+
*/
|
|
321
|
+
export function inspectBugTrack({ boardStages = null, openBugCount = 0 } = {}) {
|
|
322
|
+
if (!boardStages || openBugCount <= 0) return { missingTrackStages: [] };
|
|
323
|
+
return {
|
|
324
|
+
missingTrackStages: STAGE_TRACKS.Bug.filter(s => !boardStages.includes(s)),
|
|
325
|
+
};
|
|
298
326
|
}
|
|
299
327
|
|
|
300
328
|
async function checkBoardHygiene(ctx) {
|
|
@@ -315,6 +343,7 @@ async function checkBoardHygiene(ctx) {
|
|
|
315
343
|
}
|
|
316
344
|
|
|
317
345
|
let repoLabels = null;
|
|
346
|
+
let openBugCount = 0;
|
|
318
347
|
if (ctx.token && cfg.owner && cfg.repo) {
|
|
319
348
|
try {
|
|
320
349
|
const res = await makeOctokit(ctx.token)
|
|
@@ -323,9 +352,17 @@ async function checkBoardHygiene(ctx) {
|
|
|
323
352
|
} catch {
|
|
324
353
|
// labels não verificáveis agora — segue só com o board
|
|
325
354
|
}
|
|
355
|
+
try {
|
|
356
|
+
const bugs = await makeOctokit(ctx.token).paginate('GET /repos/{owner}/{repo}/issues', {
|
|
357
|
+
owner: cfg.owner, repo: cfg.repo, labels: '[BUG]', state: 'open', per_page: 100,
|
|
358
|
+
});
|
|
359
|
+
openBugCount = bugs.filter(i => !i.pull_request).length;
|
|
360
|
+
} catch {
|
|
361
|
+
// sem acesso às issues — o check da trilha do Bug fica de fora
|
|
362
|
+
}
|
|
326
363
|
}
|
|
327
364
|
|
|
328
|
-
const { unknownStages, missingStages, orphanLabels, missingLabels } =
|
|
365
|
+
const { unknownStages, retiredStages, missingStages, orphanLabels, missingLabels } =
|
|
329
366
|
inspectBoardHygiene({ boardStages, repoLabels });
|
|
330
367
|
|
|
331
368
|
const notes = [];
|
|
@@ -343,10 +380,32 @@ async function checkBoardHygiene(ctx) {
|
|
|
343
380
|
'Mova os itens para uma etapa do fluxo e remova a coluna.'
|
|
344
381
|
);
|
|
345
382
|
}
|
|
383
|
+
for (const stage of retiredStages) {
|
|
384
|
+
status = 'warn';
|
|
385
|
+
notes.push(
|
|
386
|
+
`Etapa descontinuada ainda no board: ${stage.name} (removida do fluxo na v${stage.removedIn}). ` +
|
|
387
|
+
`Mova os itens dela para ${stage.replacedBy} e apague a coluna nas configurações do campo ` +
|
|
388
|
+
'"Etapa"; depois rode `refresh --config` para tirar o id do .spec-wave.json.'
|
|
389
|
+
);
|
|
390
|
+
}
|
|
346
391
|
if (missingStages.length > 0) {
|
|
347
392
|
status = 'warn';
|
|
348
|
-
notes.push(
|
|
393
|
+
notes.push(
|
|
394
|
+
`Etapas do RFC-001 ausentes no board: ${missingStages.join(', ')} — rode ` +
|
|
395
|
+
'`refresh --stages --dry-run` para ver o plano, ou crie a coluna à mão nas configurações ' +
|
|
396
|
+
'do campo "Etapa" e rode `refresh --config`.'
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
const { missingTrackStages } = inspectBugTrack({ boardStages, openBugCount });
|
|
400
|
+
if (missingTrackStages.length > 0) {
|
|
401
|
+
status = 'warn';
|
|
402
|
+
notes.push(
|
|
403
|
+
`${openBugCount} Bug(s) aberto(s), mas o board não tem: ${missingTrackStages.join(', ')}. ` +
|
|
404
|
+
'Um Bug movido para uma etapa inexistente fica SEM Etapa e some das telas — ' +
|
|
405
|
+
'rode `refresh --stages`.'
|
|
406
|
+
);
|
|
349
407
|
}
|
|
408
|
+
|
|
350
409
|
if (repoLabels) {
|
|
351
410
|
if (orphanLabels.length > 0) {
|
|
352
411
|
status = 'warn';
|
|
@@ -504,10 +563,22 @@ async function checkAi(ctx) {
|
|
|
504
563
|
);
|
|
505
564
|
} else {
|
|
506
565
|
notes.push(
|
|
507
|
-
`Saída estruturada da crítica:
|
|
566
|
+
`Saída estruturada da crítica: tool call forçado (${provider.value}) ` +
|
|
508
567
|
`· strict=${supportsStrictSchema(critiqueModel) ? 'sim' : 'não'} neste modelo.`
|
|
509
568
|
);
|
|
510
569
|
}
|
|
570
|
+
// O backend anthropic não chama a API — sobe o Claude Code CLI como
|
|
571
|
+
// subprocesso, que não existe no runner dos workflows (só há setup-node).
|
|
572
|
+
if (provider.value === 'anthropic') {
|
|
573
|
+
problems.push(
|
|
574
|
+
'O provider `anthropic` NÃO roda nos GitHub Actions: ele sobe o Claude Code CLI como ' +
|
|
575
|
+
'subprocesso, ausente no runner. As Actions de spec/plan/decompose vão falhar. ' +
|
|
576
|
+
'Troque para `"provider": "openrouter"` no .spec-wave.json (e adicione o secret ' +
|
|
577
|
+
'OPENROUTER_API_KEY), ou instale o Claude Code no workflow e defina ' +
|
|
578
|
+
'SPEC_WAVE_ALLOW_ANTHROPIC_IN_CI=1. Localmente o anthropic funciona.'
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
511
582
|
if (process.env[provider.secret]) {
|
|
512
583
|
notes.push(`${provider.secret} presente no ambiente local.`);
|
|
513
584
|
} else {
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Gera o bug.md (RFC-004 §5) — espelho do generate-spec, com três diferenças
|
|
2
|
+
// que vêm da natureza do artefato:
|
|
3
|
+
//
|
|
4
|
+
// 1. O relato mora nos COMENTÁRIOS, não só no corpo. Nas origens "reprovação
|
|
5
|
+
// de QA" e "suporte", o corpo da issue é uma linha e o que descreve o
|
|
6
|
+
// defeito vem depois, em comentário. Por isso eles entram no payload.
|
|
7
|
+
// 2. A crítica adversarial recebe o relato original junto do documento: a
|
|
8
|
+
// pergunta que ela responde é se a causa raiz proposta explica OS SINTOMAS
|
|
9
|
+
// RELATADOS — sem o relato, ela só avalia coerência interna.
|
|
10
|
+
// 3. Escreve em docs/bugs/<slug>/, fora de docs/features/.
|
|
11
|
+
import { execSync } from 'node:child_process';
|
|
12
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { resolveToken } from '../api/auth.mjs';
|
|
14
|
+
import {
|
|
15
|
+
getIssue, removeLabel, addLabel, commentOnIssue, listIssueComments,
|
|
16
|
+
} from '../api/github-rest.mjs';
|
|
17
|
+
import { generateDocument } from '../lib/claude.mjs';
|
|
18
|
+
import { recordUsage } from '../lib/usage-report.mjs';
|
|
19
|
+
import { loadConfig } from '../lib/project-root.mjs';
|
|
20
|
+
import { loadPrompt, toolFreeSystemPrompt } from '../lib/prompt-loader.mjs';
|
|
21
|
+
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
22
|
+
import { bugDocPaths } from '../lib/bug-doc.mjs';
|
|
23
|
+
import {
|
|
24
|
+
runCritique, resolveCritiqueAttempt, renderNeedsHumanComment,
|
|
25
|
+
} from '../lib/critique.mjs';
|
|
26
|
+
import {
|
|
27
|
+
LABEL_BUG, LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN, REQUIRED_BUG_SECTIONS,
|
|
28
|
+
TARGET_LANGUAGE, DEFAULT_MAX_CRITIQUE_ATTEMPTS, labelNames,
|
|
29
|
+
} from '../config.mjs';
|
|
30
|
+
|
|
31
|
+
// Relato = corpo + comentários humanos, na ordem cronológica. Comentários do
|
|
32
|
+
// próprio spec-wave são ruído aqui (críticas anteriores, relatórios de uso):
|
|
33
|
+
// realimentá-los faria o modelo auditar a si mesmo em vez do defeito.
|
|
34
|
+
function buildReport(issue, comments) {
|
|
35
|
+
const parts = [`### Descrição da issue\n\n${issue.body || '(sem descrição)'}`];
|
|
36
|
+
const humanos = (comments || []).filter(c => !isSpecWaveComment(c.body));
|
|
37
|
+
for (const c of humanos) {
|
|
38
|
+
const autor = c.user?.login || 'desconhecido';
|
|
39
|
+
parts.push(`### Comentário de @${autor}\n\n${c.body}`);
|
|
40
|
+
}
|
|
41
|
+
return parts.join('\n\n');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isSpecWaveComment(body) {
|
|
45
|
+
const t = String(body || '');
|
|
46
|
+
return t.includes('<!-- spec-wave:') || t.includes('**(spec-wave)**') || t.includes('(spec-wave)');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function generateBug({ issueNumber }) {
|
|
50
|
+
const token = await resolveToken();
|
|
51
|
+
const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
52
|
+
const { root } = loadConfig();
|
|
53
|
+
const n = parseInt(issueNumber, 10);
|
|
54
|
+
|
|
55
|
+
if (!owner || !repo) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
'GITHUB_REPOSITORY env var não definida.\n' +
|
|
58
|
+
'Este comando roda no GitHub Actions. Para testar localmente:\n' +
|
|
59
|
+
' GITHUB_REPOSITORY=owner/repo spec-wave generate-bug --issue-number 1'
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log(`Buscando issue #${n}...`);
|
|
64
|
+
const issue = await getIssue(token, owner, repo, n);
|
|
65
|
+
|
|
66
|
+
// Guarda invertida em relação ao generate-spec: aqui só Bug passa.
|
|
67
|
+
const type = detectIssueType(issue);
|
|
68
|
+
if (type !== 'Bug') {
|
|
69
|
+
console.log(`Issue #${n} é ${type}: bug.md é gerado só para Bug.`);
|
|
70
|
+
await removeLabel(token, owner, repo, n, LABEL_BUG);
|
|
71
|
+
await commentOnIssue(
|
|
72
|
+
token, owner, repo, n,
|
|
73
|
+
`ℹ️ **bug.md não gerado:** o tipo **${type}** não usa esse artefato. ` +
|
|
74
|
+
'Nenhum arquivo foi criado.'
|
|
75
|
+
).catch(() => {});
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const issueLabels = labelNames(issue);
|
|
80
|
+
if (issueLabels.includes(LABEL_NEEDS_HUMAN)) {
|
|
81
|
+
console.log(`Issue #${n} está com ${LABEL_NEEDS_HUMAN}: geração bloqueada.`);
|
|
82
|
+
await removeLabel(token, owner, repo, n, LABEL_BUG);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const { slug, dirRel, fileRel, dirAbs, fileAbs } = bugDocPaths(issue.title, root);
|
|
87
|
+
|
|
88
|
+
const comments = await listIssueComments(token, owner, repo, n).catch(() => []);
|
|
89
|
+
const report = buildReport(issue, comments);
|
|
90
|
+
|
|
91
|
+
const payload = {
|
|
92
|
+
metadata: {
|
|
93
|
+
bug_title: issue.title,
|
|
94
|
+
labels: issueLabels,
|
|
95
|
+
required_sections: REQUIRED_BUG_SECTIONS,
|
|
96
|
+
},
|
|
97
|
+
report: { raw: report },
|
|
98
|
+
};
|
|
99
|
+
const userContent =
|
|
100
|
+
`Gere o bug.md a partir deste payload JSON:\n\n${JSON.stringify(payload, null, 2)}`;
|
|
101
|
+
|
|
102
|
+
const usageEntries = [];
|
|
103
|
+
try {
|
|
104
|
+
console.log(`Gerando bug.md para: ${issue.title}`);
|
|
105
|
+
const systemPrompt = toolFreeSystemPrompt(loadPrompt('bug', { cwd: root }));
|
|
106
|
+
const { content } = await generateDocument(systemPrompt, userContent, {
|
|
107
|
+
action: 'bug',
|
|
108
|
+
labels: issueLabels,
|
|
109
|
+
lint: { lang: TARGET_LANGUAGE },
|
|
110
|
+
withReport: true,
|
|
111
|
+
usage: usageEntries,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
mkdirSync(dirAbs, { recursive: true });
|
|
115
|
+
writeFileSync(fileAbs, content, 'utf-8');
|
|
116
|
+
|
|
117
|
+
const git = (cmd) => execSync(cmd, { stdio: 'inherit' });
|
|
118
|
+
git('git config user.email "spec-wave[bot]@github.com"');
|
|
119
|
+
git('git config user.name "spec-wave[bot]"');
|
|
120
|
+
git(`git add "${fileAbs}"`);
|
|
121
|
+
git(`git commit -m "docs: generate bug.md for ${slug} [spec-wave]"`);
|
|
122
|
+
git('git pull --rebase');
|
|
123
|
+
git('git push');
|
|
124
|
+
|
|
125
|
+
await removeLabel(token, owner, repo, n, LABEL_BUG);
|
|
126
|
+
|
|
127
|
+
// Crítica adversarial: NÃO fatal. Um bug.md gerado e criticado como grave
|
|
128
|
+
// ainda é melhor que nenhum — o portão é a label, verificada na triagem.
|
|
129
|
+
const critique = await critiqueBugDoc({
|
|
130
|
+
token, owner, repo, issueNumber: n, issue,
|
|
131
|
+
bugDoc: content, report, labels: issueLabels, cwd: root, usage: usageEntries,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
await commentOnIssue(
|
|
135
|
+
token, owner, repo, n,
|
|
136
|
+
'🐞 **bug.md gerado automaticamente!**\n\n' +
|
|
137
|
+
`📄 Arquivo: [\`${fileRel}\`](https://github.com/${owner}/${repo}/blob/main/${fileRel})\n\n` +
|
|
138
|
+
'Revise a **causa raiz** e o **teste de regressão** — são as duas seções que decidem se ' +
|
|
139
|
+
'a correção ataca o defeito ou o sintoma. Quando estiver pronto, valide com:\n' +
|
|
140
|
+
`\`\`\`\ngh issue edit ${n} --add-label "spec-wave:ready"\n\`\`\`` +
|
|
141
|
+
(critique?.blocked ? '\n\n⛔ A crítica adversarial encontrou problemas graves (acima).' : '')
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
console.log(`bug.md criado em: ${fileRel} (dir: ${dirRel})`);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
// Mesmo motivo do generate-spec: sem remover a label, re-aplicá-la não
|
|
147
|
+
// emite evento e a issue vira beco sem saída.
|
|
148
|
+
await removeLabel(token, owner, repo, n, LABEL_BUG).catch(() => {});
|
|
149
|
+
await commentOnIssue(
|
|
150
|
+
token, owner, repo, n,
|
|
151
|
+
'❌ **Falha ao gerar o bug.md**\n\n' +
|
|
152
|
+
`\`\`\`\n${err.message}\n\`\`\`\n\n` +
|
|
153
|
+
`A label \`${LABEL_BUG}\` foi removida para destravar o gatilho — ` +
|
|
154
|
+
'adicione-a de novo para tentar outra vez.'
|
|
155
|
+
).catch(() => {});
|
|
156
|
+
throw err;
|
|
157
|
+
} finally {
|
|
158
|
+
await recordUsage({ token, owner, repo, issueNumber: n, entries: usageEntries });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Roda a crítica e reflete o veredito em labels/comentário. Best-effort: uma
|
|
163
|
+
// falha da crítica não invalida o documento já commitado.
|
|
164
|
+
async function critiqueBugDoc({
|
|
165
|
+
token, owner, repo, issueNumber, issue, bugDoc, report, labels, cwd, usage,
|
|
166
|
+
}) {
|
|
167
|
+
try {
|
|
168
|
+
const comments = await listIssueComments(token, owner, repo, issueNumber).catch(() => []);
|
|
169
|
+
const { attempt, blocked } = resolveCritiqueAttempt({
|
|
170
|
+
comments, labels, kind: 'bug', maxAttempts: DEFAULT_MAX_CRITIQUE_ATTEMPTS,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
if (blocked) {
|
|
174
|
+
await addLabel(token, owner, repo, issueNumber, LABEL_NEEDS_HUMAN);
|
|
175
|
+
await commentOnIssue(
|
|
176
|
+
token, owner, repo, issueNumber,
|
|
177
|
+
renderNeedsHumanComment({ kind: 'bug', maxAttempts: DEFAULT_MAX_CRITIQUE_ATTEMPTS })
|
|
178
|
+
);
|
|
179
|
+
return { blocked: true };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const result = await runCritique({
|
|
183
|
+
kind: 'bug', bugDoc, bugReport: report, attempt, labels, usage, cwd,
|
|
184
|
+
});
|
|
185
|
+
await commentOnIssue(token, owner, repo, issueNumber, result.markdown);
|
|
186
|
+
if (result.grave) {
|
|
187
|
+
await addLabel(token, owner, repo, issueNumber, LABEL_CRITIQUE_FAILED);
|
|
188
|
+
return { blocked: true };
|
|
189
|
+
}
|
|
190
|
+
return { blocked: false };
|
|
191
|
+
} catch (err) {
|
|
192
|
+
console.error(`Crítica do bug.md falhou (não fatal): ${err.message}`);
|
|
193
|
+
return { blocked: false };
|
|
194
|
+
}
|
|
195
|
+
}
|