@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
package/src/commands/refresh.mjs
CHANGED
|
@@ -4,20 +4,174 @@ import { readFileSync, writeFileSync } from 'node:fs';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { resolveToken } from '../api/auth.mjs';
|
|
7
|
-
import { CONFIG_FILE } from '../config.mjs';
|
|
7
|
+
import { CONFIG_FILE, STATUS_OPTIONS } from '../config.mjs';
|
|
8
8
|
import { findConfigPath } from '../lib/project-root.mjs';
|
|
9
|
-
import { getProjectSnapshot } from '../api/github-graphql.mjs';
|
|
9
|
+
import { getProjectSnapshot, updateStatusField } from '../api/github-graphql.mjs';
|
|
10
10
|
|
|
11
11
|
const __dir = path.dirname(fileURLToPath(import.meta.url));
|
|
12
12
|
const pkg = JSON.parse(readFileSync(path.join(__dir, '..', '..', 'package.json'), 'utf-8'));
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Plano de sincronização das opções do campo "Etapa" (função PURA).
|
|
16
|
+
*
|
|
17
|
+
* A operação é DELIBERADAMENTE aditiva: `ordered` contém tudo que já existe no
|
|
18
|
+
* board mais as etapas canônicas que faltam, cada uma na posição canônica. Nada
|
|
19
|
+
* é removido — nem coluna inventada, nem etapa descontinuada.
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ E cada opção existente leva seu **id** junto. O contrato da API é o que
|
|
22
|
+
* torna isso obrigatório: `updateProjectV2Field` SUBSTITUI o conjunto de
|
|
23
|
+
* opções, e o que liga um item do board a uma opção é o ID — não o nome. Uma
|
|
24
|
+
* opção reenviada sem id é RECRIADA com id novo, e todo item que estava nela
|
|
25
|
+
* fica sem Etapa, em silêncio.
|
|
26
|
+
*
|
|
27
|
+
* A versão anterior preservava só o nome. No smoke test do RFC-004 isso apagou
|
|
28
|
+
* a Etapa de uma Feature que estava em 🎯 Priorizado, enquanto o comando
|
|
29
|
+
* relatava "nenhuma é removida" — verdade sobre os nomes, mentira sobre o
|
|
30
|
+
* board.
|
|
31
|
+
*
|
|
32
|
+
* @param {Record<string, string>} current opções hoje no board: nome → id
|
|
33
|
+
* @param {Array<{name: string, color: string}>} canonical STATUS_OPTIONS
|
|
34
|
+
* @returns {{ missing: string[], preserved: string[],
|
|
35
|
+
* ordered: Array<{name: string, color: string, id?: string}> }}
|
|
36
|
+
*/
|
|
37
|
+
export function planStageSync(current, canonical) {
|
|
38
|
+
const atual = current || {};
|
|
39
|
+
const idDe = (name) => atual[name];
|
|
40
|
+
const canonicalNames = new Set(canonical.map(o => o.name));
|
|
41
|
+
|
|
42
|
+
const missing = canonical.filter(o => !idDe(o.name)).map(o => o.name);
|
|
43
|
+
// Colunas que o board tem e o fluxo canônico não conhece (inventadas ou
|
|
44
|
+
// descontinuadas). Vão para o fim, preservando a ordem relativa que tinham.
|
|
45
|
+
const preserved = Object.keys(atual).filter(name => !canonicalNames.has(name));
|
|
46
|
+
|
|
47
|
+
const ordered = [
|
|
48
|
+
...canonical.map(o => {
|
|
49
|
+
const id = idDe(o.name);
|
|
50
|
+
return id ? { name: o.name, color: o.color, id } : { name: o.name, color: o.color };
|
|
51
|
+
}),
|
|
52
|
+
...preserved.map(name => ({ name, color: 'GRAY', id: atual[name] })),
|
|
53
|
+
];
|
|
54
|
+
return { missing, preserved, ordered };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Aplica o plano de `planStageSync` no campo "Etapa" do Project.
|
|
59
|
+
*
|
|
60
|
+
* Escreve só quando há etapa canônica faltando, e verifica o resultado relendo
|
|
61
|
+
* o Project: se alguma opção que existia antes sumiu, grita — é o único jeito
|
|
62
|
+
* de o usuário descobrir a tempo que itens perderam a Etapa.
|
|
63
|
+
*
|
|
64
|
+
* @returns {Promise<boolean>} true se o comando deve continuar
|
|
65
|
+
*/
|
|
66
|
+
async function syncStages(token, snapshot, options) {
|
|
67
|
+
const etapa = snapshot.fields?.['Etapa'];
|
|
68
|
+
if (!etapa?.id) {
|
|
69
|
+
p.log.error('Campo "Etapa" não encontrado no Project — nada a sincronizar.');
|
|
70
|
+
process.exitCode = 1;
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Mapa nome → id: é o id que preserva o vínculo dos itens com a opção.
|
|
75
|
+
const current = etapa.options || {};
|
|
76
|
+
const nomesAntes = Object.keys(current);
|
|
77
|
+
const { missing, preserved, ordered } = planStageSync(current, STATUS_OPTIONS);
|
|
78
|
+
|
|
79
|
+
if (missing.length === 0) {
|
|
80
|
+
p.log.success(`Campo "Etapa" já tem as ${STATUS_OPTIONS.length} etapas canônicas.`);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
p.note(
|
|
85
|
+
`${chalk.green('+')} acrescentar: ${missing.join(', ')}\n` +
|
|
86
|
+
(preserved.length
|
|
87
|
+
? `${chalk.dim('=')} preservar (fora do fluxo canônico): ${preserved.join(', ')}\n`
|
|
88
|
+
: '') +
|
|
89
|
+
`${chalk.dim('=')} preservar (canônicas já presentes): ${nomesAntes.length - preserved.length}\n\n` +
|
|
90
|
+
chalk.dim(`Resultado: ${ordered.length} opções. Nenhuma é removida.`),
|
|
91
|
+
'Plano para o campo "Etapa"'
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
if (options.dryRun) {
|
|
95
|
+
p.outro('Dry-run: nada foi enviado ao GitHub.');
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!options.yes) {
|
|
100
|
+
const go = await p.confirm({
|
|
101
|
+
message: 'Reescrever as opções do campo "Etapa" com esse conjunto?',
|
|
102
|
+
initialValue: false,
|
|
103
|
+
});
|
|
104
|
+
if (p.isCancel(go) || !go) {
|
|
105
|
+
p.outro('Cancelado — nada foi alterado.');
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const spinner = p.spinner();
|
|
111
|
+
spinner.start('Atualizando o campo "Etapa"...');
|
|
112
|
+
try {
|
|
113
|
+
await updateStatusField(token, etapa.id, ordered);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
spinner.stop('');
|
|
116
|
+
p.log.error(`Falha ao atualizar o campo "Etapa": ${err.message}`);
|
|
117
|
+
process.exitCode = 1;
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Verificação pós-escrita: releitura, não confiança na mutação.
|
|
122
|
+
let after;
|
|
123
|
+
try {
|
|
124
|
+
after = await getProjectSnapshot(token, snapshot.id);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
spinner.stop('');
|
|
127
|
+
p.log.warn(`Campo atualizado, mas a verificação falhou: ${err.message}`);
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
const depois = after?.fields?.['Etapa']?.options || {};
|
|
131
|
+
const now = Object.keys(depois);
|
|
132
|
+
const lost = nomesAntes.filter(name => !now.includes(name));
|
|
133
|
+
// A verificação que importa: o ID de cada opção preexistente tem que ser o
|
|
134
|
+
// MESMO. Conferir só o nome era o que deixava passar o pior desfecho — as 12
|
|
135
|
+
// opções presentes, todos os ids trocados, e o board inteiro sem Etapa.
|
|
136
|
+
const recriadas = nomesAntes.filter(name => depois[name] && depois[name] !== current[name]);
|
|
137
|
+
const stillMissing = STATUS_OPTIONS.map(o => o.name).filter(name => !now.includes(name));
|
|
138
|
+
spinner.stop(`Campo "Etapa" com ${now.length} opções.`);
|
|
139
|
+
|
|
140
|
+
if (lost.length > 0) {
|
|
141
|
+
p.log.error(
|
|
142
|
+
`Opções que existiam SUMIRAM: ${lost.join(', ')}. Os itens que estavam nelas ficaram ` +
|
|
143
|
+
'sem Etapa — recrie a coluna com o mesmo nome e reposicione os itens.'
|
|
144
|
+
);
|
|
145
|
+
process.exitCode = 1;
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
if (recriadas.length > 0) {
|
|
149
|
+
p.log.error(
|
|
150
|
+
`Opções RECRIADAS com id novo: ${recriadas.join(', ')}. Os itens que estavam nelas ` +
|
|
151
|
+
'perderam a Etapa — reposicione-os no board. Isto é um bug do comando, não do seu ' +
|
|
152
|
+
'Project: reporte com a saída acima.'
|
|
153
|
+
);
|
|
154
|
+
process.exitCode = 1;
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
if (stillMissing.length > 0) {
|
|
158
|
+
p.log.warn(`Etapas canônicas ainda ausentes: ${stillMissing.join(', ')}.`);
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
p.log.success(`Etapas acrescentadas: ${missing.join(', ')}.`);
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
14
165
|
// Re-consulta o GitHub Project e reescreve o .spec-wave.json local com os dados
|
|
15
166
|
// atuais (id/number/url/title do Project, IDs do campo Etapa e das opções, e a
|
|
16
167
|
// versão da CLI). Útil para repositórios inicializados antes do enriquecimento,
|
|
17
168
|
// ou quando o Project foi renomeado/teve campos alterados.
|
|
18
169
|
export async function refresh(options = {}) {
|
|
19
|
-
if (!options.config) {
|
|
20
|
-
p.log.error(
|
|
170
|
+
if (!options.config && !options.stages) {
|
|
171
|
+
p.log.error(
|
|
172
|
+
'Nada a fazer. Use `spec-wave refresh --config` para atualizar o .spec-wave.json, ' +
|
|
173
|
+
'ou `--stages` para acrescentar as colunas canônicas que faltam no campo "Etapa".'
|
|
174
|
+
);
|
|
21
175
|
process.exitCode = 1;
|
|
22
176
|
return;
|
|
23
177
|
}
|
|
@@ -57,7 +211,7 @@ export async function refresh(options = {}) {
|
|
|
57
211
|
return;
|
|
58
212
|
}
|
|
59
213
|
|
|
60
|
-
p.intro(chalk.bold(
|
|
214
|
+
p.intro(chalk.bold(`spec-wave refresh${options.stages ? ' --stages' : ' --config'}`));
|
|
61
215
|
|
|
62
216
|
const spinner = p.spinner();
|
|
63
217
|
spinner.start('Consultando o GitHub Project...');
|
|
@@ -78,6 +232,18 @@ export async function refresh(options = {}) {
|
|
|
78
232
|
}
|
|
79
233
|
spinner.stop('Project consultado.');
|
|
80
234
|
|
|
235
|
+
if (options.stages) {
|
|
236
|
+
const ok = await syncStages(token, snapshot, options);
|
|
237
|
+
if (!ok) return;
|
|
238
|
+
// Depois de mexer no board, o .spec-wave.json guarda ids de opção velhos —
|
|
239
|
+
// relê o Project para que o resto do comando grave o estado novo.
|
|
240
|
+
snapshot = await getProjectSnapshot(token, projectId);
|
|
241
|
+
if (!options.config) {
|
|
242
|
+
p.outro('Campo "Etapa" sincronizado. Rode `spec-wave refresh --config` para atualizar o .spec-wave.json.');
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
81
247
|
// Remove campos legados (versões anteriores gravavam etapaFieldId/stageOptions soltos).
|
|
82
248
|
const { etapaFieldId: _e, stageOptions: _s, ...projectRest } = config.project;
|
|
83
249
|
const updated = {
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Triagem de Bug pela CLI (RFC-004 §4.1) — o mesmo desfecho que a tela do PM
|
|
2
|
+
// oferece, para quem trabalha no terminal ou automatiza.
|
|
3
|
+
//
|
|
4
|
+
// Três saídas, e nenhuma delas é "editar": aceitar (→ ✅ Ready), rejeitar
|
|
5
|
+
// (fecha) e duplicar (fecha, apontando a original). Corrigir o relato é
|
|
6
|
+
// conversa na issue.
|
|
7
|
+
//
|
|
8
|
+
// Rejeitar e duplicar NÃO mexem na Etapa: ela nunca retrocede, e um bug
|
|
9
|
+
// rejeitado não avançou para lugar nenhum — quem o tira das filas é o estado
|
|
10
|
+
// `closed` da issue.
|
|
11
|
+
import * as p from '@clack/prompts';
|
|
12
|
+
import chalk from 'chalk';
|
|
13
|
+
import { resolveToken } from '../api/auth.mjs';
|
|
14
|
+
import {
|
|
15
|
+
getIssue, addLabel, removeLabel, commentOnIssue, setIssueState,
|
|
16
|
+
} from '../api/github-rest.mjs';
|
|
17
|
+
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
18
|
+
import { loadProjectConfig, resolveField, advanceToStage } from '../lib/board.mjs';
|
|
19
|
+
import { loadConfig } from '../lib/project-root.mjs';
|
|
20
|
+
import { resolveTriageAction, canAcceptBug } from '../lib/bug-triage.mjs';
|
|
21
|
+
import {
|
|
22
|
+
CONFIG_FILE, STAGE_READY, PROGRESS_TODO, PRIORITY_LABELS,
|
|
23
|
+
LABEL_TRIAGED, LABEL_DUPLICATE, LABEL_WONT_FIX, labelNames,
|
|
24
|
+
} from '../config.mjs';
|
|
25
|
+
|
|
26
|
+
const PRIORITIES = PRIORITY_LABELS.map(l => l.name);
|
|
27
|
+
|
|
28
|
+
export async function triage({ action: actionArg, issue: issueArg, reason, of, severity }) {
|
|
29
|
+
const { action, error: actionError } = resolveTriageAction(actionArg);
|
|
30
|
+
if (actionError) {
|
|
31
|
+
p.log.error(actionError);
|
|
32
|
+
process.exitCode = 1;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const issueNumber = parseInt(String(issueArg).replace('#', ''), 10);
|
|
37
|
+
if (!Number.isInteger(issueNumber) || issueNumber <= 0) {
|
|
38
|
+
p.log.error(`Issue inválida: "${issueArg}". Use o número da issue, ex.: 42 ou #42.`);
|
|
39
|
+
process.exitCode = 1;
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (severity && !PRIORITIES.includes(severity)) {
|
|
43
|
+
p.log.error(`Severidade inválida: ${severity}. Use uma de: ${PRIORITIES.join(', ')}.`);
|
|
44
|
+
process.exitCode = 1;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
49
|
+
const { config, root } = loadConfig();
|
|
50
|
+
const owner = envOwner || config?.owner;
|
|
51
|
+
const repo = envRepo || config?.repo;
|
|
52
|
+
if (!owner || !repo) {
|
|
53
|
+
p.log.error(
|
|
54
|
+
'Não foi possível determinar owner/repo.\n' +
|
|
55
|
+
`Rode dentro de um repositório com ${CONFIG_FILE} ou defina GITHUB_REPOSITORY=owner/repo.`
|
|
56
|
+
);
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let token;
|
|
62
|
+
try {
|
|
63
|
+
token = await resolveToken();
|
|
64
|
+
} catch (err) {
|
|
65
|
+
p.log.error(err.message);
|
|
66
|
+
process.exitCode = 1;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
p.intro(chalk.bold(`spec-wave triage ${action} #${issueNumber}`));
|
|
71
|
+
|
|
72
|
+
let issue;
|
|
73
|
+
try {
|
|
74
|
+
issue = await getIssue(token, owner, repo, issueNumber);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
p.log.error(`Não foi possível ler a issue #${issueNumber}: ${err.message}`);
|
|
77
|
+
process.exitCode = 1;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (detectIssueType(issue) !== 'Bug') {
|
|
82
|
+
p.log.error(`#${issueNumber} não é um Bug — a triagem só se aplica a defeitos.`);
|
|
83
|
+
process.exitCode = 1;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const labels = labelNames(issue);
|
|
88
|
+
const severidadeAtual = severity || labels.find(n => PRIORITIES.includes(n)) || null;
|
|
89
|
+
|
|
90
|
+
if (action === 'accept') {
|
|
91
|
+
const { ok, error } = canAcceptBug({ severity: severidadeAtual, labels });
|
|
92
|
+
if (!ok) {
|
|
93
|
+
p.log.error(error);
|
|
94
|
+
process.exitCode = 1;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Reclassificação: troca a label em vez de acumular. Duas prioridades na
|
|
99
|
+
// mesma issue tornam a ordem da fila indefinida.
|
|
100
|
+
if (severity) {
|
|
101
|
+
for (const outra of PRIORITIES) {
|
|
102
|
+
if (outra !== severity) await removeLabel(token, owner, repo, issueNumber, outra).catch(() => {});
|
|
103
|
+
}
|
|
104
|
+
await addLabel(token, owner, repo, issueNumber, severity);
|
|
105
|
+
p.log.info(`Severidade → ${severity}.`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
await addLabel(token, owner, repo, issueNumber, LABEL_TRIAGED);
|
|
109
|
+
await commentOnIssue(token, owner, repo, issueNumber,
|
|
110
|
+
'**Triado:** aceito para a fila técnica.').catch(() => {});
|
|
111
|
+
await moveToReady({ token, issueNumber, issue, root });
|
|
112
|
+
|
|
113
|
+
p.outro(`${chalk.green('✓')} Bug #${issueNumber} aceito → ${STAGE_READY}.`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (action === 'reject') {
|
|
118
|
+
if (!reason || !reason.trim()) {
|
|
119
|
+
p.log.error('Rejeitar exige --reason "<motivo>".');
|
|
120
|
+
process.exitCode = 1;
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
await addLabel(token, owner, repo, issueNumber, LABEL_WONT_FIX);
|
|
124
|
+
await addLabel(token, owner, repo, issueNumber, LABEL_TRIAGED);
|
|
125
|
+
await commentOnIssue(token, owner, repo, issueNumber,
|
|
126
|
+
`**Rejeitado na triagem:** ${reason.trim()}`).catch(() => {});
|
|
127
|
+
await setIssueState(token, owner, repo, issueNumber, 'closed');
|
|
128
|
+
p.outro(`${chalk.green('✓')} Bug #${issueNumber} rejeitado e fechado.`);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// duplicate
|
|
133
|
+
const original = parseInt(String(of || '').replace('#', ''), 10);
|
|
134
|
+
if (!Number.isInteger(original) || original <= 0) {
|
|
135
|
+
p.log.error('Marcar como duplicata exige --of <número da issue original>.');
|
|
136
|
+
process.exitCode = 1;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (original === issueNumber) {
|
|
140
|
+
p.log.error('Uma issue não pode ser duplicata de si mesma.');
|
|
141
|
+
process.exitCode = 1;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
await addLabel(token, owner, repo, issueNumber, LABEL_DUPLICATE);
|
|
146
|
+
await addLabel(token, owner, repo, issueNumber, LABEL_TRIAGED);
|
|
147
|
+
await commentOnIssue(token, owner, repo, issueNumber, `**Duplicata de #${original}.**`).catch(() => {});
|
|
148
|
+
// Comentar também na original: sem isso, quem acompanha #original não fica
|
|
149
|
+
// sabendo que há outro relato do mesmo defeito — e o contexto extra está lá.
|
|
150
|
+
await commentOnIssue(token, owner, repo, original,
|
|
151
|
+
`#${issueNumber} foi marcada como duplicata desta issue.`).catch(() => {});
|
|
152
|
+
await setIssueState(token, owner, repo, issueNumber, 'closed');
|
|
153
|
+
|
|
154
|
+
p.outro(`${chalk.green('✓')} Bug #${issueNumber} marcado como duplicata de #${original} e fechado.`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function moveToReady({ token, issueNumber, issue, root }) {
|
|
158
|
+
const { project, error } = loadProjectConfig({ cwd: root || process.cwd() });
|
|
159
|
+
if (error) {
|
|
160
|
+
p.log.warn(`${error} — board não atualizado.`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
|
|
165
|
+
const statusField = await resolveField(token, project, 'Status').catch(() => null);
|
|
166
|
+
const moved = await advanceToStage(
|
|
167
|
+
token, project, etapaField, statusField, issue.node_id, STAGE_READY, PROGRESS_TODO);
|
|
168
|
+
if (!moved) {
|
|
169
|
+
p.log.info(`#${issueNumber} já está em ${STAGE_READY} ou etapa posterior — mantido.`);
|
|
170
|
+
}
|
|
171
|
+
} catch (err) {
|
|
172
|
+
p.log.warn(`Falha ao mover no board: ${err.message}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
package/src/commands/update.mjs
CHANGED
|
@@ -20,8 +20,9 @@ import {
|
|
|
20
20
|
import { readTemplate } from '../lib/templates.mjs';
|
|
21
21
|
import {
|
|
22
22
|
TARGETS, SKILL_SOURCE, CLI_VERSION, parseSkill, renderContent,
|
|
23
|
-
mergeAgentsFile, resolveDest, isDetected, skillCopyReason,
|
|
23
|
+
mergeAgentsFile, resolveDest, isDetected, skillCopyReason, versionBanner,
|
|
24
24
|
} from './install-skill.mjs';
|
|
25
|
+
import { planPluginSkillFiles } from '../lib/plugin-skills.mjs';
|
|
25
26
|
import { findConfigPath } from '../lib/project-root.mjs';
|
|
26
27
|
|
|
27
28
|
// Arquivos do repo gerenciados pela CLI (comparados com o template empacotado).
|
|
@@ -39,15 +40,27 @@ function detectSkill(parsed, baseDir, isGlobal) {
|
|
|
39
40
|
const dest = resolveDest(target, baseDir, isGlobal);
|
|
40
41
|
if (!dest) continue;
|
|
41
42
|
const reason = skillCopyReason(dest, parsed);
|
|
42
|
-
if (reason)
|
|
43
|
-
|
|
43
|
+
if (!reason) continue;
|
|
44
|
+
// `skills-dir` (Codex) não tem um `desired` único: são N arquivos, um por
|
|
45
|
+
// skill do plugin. O plano é montado na hora de aplicar.
|
|
46
|
+
if (dest.format === 'skills-dir') {
|
|
47
|
+
jobs.push({ target, dest, reason });
|
|
48
|
+
continue;
|
|
44
49
|
}
|
|
50
|
+
jobs.push({ target, dest, desired: renderContent(dest.format, parsed, CLI_VERSION), reason });
|
|
45
51
|
}
|
|
46
52
|
return jobs;
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
// Aplica a atualização de uma skill (grava o arquivo / faz merge no AGENTS.md).
|
|
50
56
|
function applySkill(job) {
|
|
57
|
+
if (job.dest.format === 'skills-dir') {
|
|
58
|
+
for (const file of planPluginSkillFiles(job.dest.path, CLI_VERSION, versionBanner)) {
|
|
59
|
+
mkdirSync(path.dirname(file.path), { recursive: true });
|
|
60
|
+
writeFileSync(file.path, file.content, 'utf-8');
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
51
64
|
const content = job.dest.format === 'agents'
|
|
52
65
|
? mergeAgentsFile(job.dest.path, job.desired)
|
|
53
66
|
: job.desired;
|
|
@@ -4,12 +4,81 @@ import { resolveToken } from '../api/auth.mjs';
|
|
|
4
4
|
import { getIssue, removeLabel, addLabel, commentOnIssue } from '../api/github-rest.mjs';
|
|
5
5
|
import { slugify } from '../lib/slugify.mjs';
|
|
6
6
|
import {
|
|
7
|
-
CONFIG_FILE, LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN,
|
|
8
|
-
REQUIRED_PLAN_SECTIONS, REQUIRED_SPEC_SECTIONS, labelNames,
|
|
7
|
+
CONFIG_FILE, LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN, LABEL_BUG, LABEL_BUG_APPROVED,
|
|
8
|
+
REQUIRED_PLAN_SECTIONS, REQUIRED_SPEC_SECTIONS, REQUIRED_BUG_SECTIONS, labelNames,
|
|
9
9
|
} from '../config.mjs';
|
|
10
10
|
import { findIncompleteDocSigns } from '../lib/doc-completeness.mjs';
|
|
11
|
+
import { bugDocPaths, findMissingSections } from '../lib/bug-doc.mjs';
|
|
12
|
+
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
11
13
|
import { loadConfig } from '../lib/project-root.mjs';
|
|
12
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Validação do bug.md (RFC-004 §5).
|
|
17
|
+
*
|
|
18
|
+
* Mesma forma da validação de Feature — seções obrigatórias + sinais de
|
|
19
|
+
* documento truncado + portões humanos da crítica — com duas diferenças:
|
|
20
|
+
* um único arquivo, e a falha NÃO devolve o item para a etapa de spec (Bug não
|
|
21
|
+
* tem etapa de spec). Reaplicar `spec-wave:bug` é o caminho de retomada.
|
|
22
|
+
*/
|
|
23
|
+
async function validateBug({ token, owner, repo, issue, issueNumber, root }) {
|
|
24
|
+
const n = parseInt(issueNumber, 10);
|
|
25
|
+
const { fileRel, fileAbs } = bugDocPaths(issue.title, root);
|
|
26
|
+
const errors = [];
|
|
27
|
+
|
|
28
|
+
const names = labelNames(issue);
|
|
29
|
+
const critiqueFailed = names.includes(LABEL_CRITIQUE_FAILED);
|
|
30
|
+
const needsHuman = names.includes(LABEL_NEEDS_HUMAN);
|
|
31
|
+
if (critiqueFailed) {
|
|
32
|
+
errors.push(
|
|
33
|
+
'🔎 A crítica adversarial apontou contradições GRAVES no `bug.md` (veja o comentário na ' +
|
|
34
|
+
`issue). Corrija e remova a label \`${LABEL_CRITIQUE_FAILED}\`.`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
if (needsHuman) {
|
|
38
|
+
errors.push(
|
|
39
|
+
`🛑 A crítica reprovou repetidas vezes e a label \`${LABEL_NEEDS_HUMAN}\` foi aplicada. ` +
|
|
40
|
+
'Uma pessoa precisa revisar o `bug.md` e remover a label.'
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!existsSync(fileAbs)) {
|
|
45
|
+
errors.push(
|
|
46
|
+
`❌ \`bug.md\` não encontrado em \`${fileRel}\` — aplique \`${LABEL_BUG}\` para gerá-lo.`
|
|
47
|
+
);
|
|
48
|
+
} else {
|
|
49
|
+
const content = readFileSync(fileAbs, 'utf-8');
|
|
50
|
+
for (const section of findMissingSections(content, REQUIRED_BUG_SECTIONS)) {
|
|
51
|
+
errors.push(`❌ Seção obrigatória ausente no bug.md: **${section}**`);
|
|
52
|
+
}
|
|
53
|
+
for (const problem of findIncompleteDocSigns(content)) {
|
|
54
|
+
errors.push(`❌ \`bug.md\` parece incompleto: ${problem}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
await removeLabel(token, owner, repo, n, 'spec-wave:ready');
|
|
59
|
+
|
|
60
|
+
if (errors.length > 0) {
|
|
61
|
+
await commentOnIssue(
|
|
62
|
+
token, owner, repo, n,
|
|
63
|
+
'⚠️ **Validação falhou — o bug.md não está pronto.**\n\n' +
|
|
64
|
+
errors.join('\n') +
|
|
65
|
+
`\n\nCorrija os problemas e adicione novamente a label \`spec-wave:ready\`.`
|
|
66
|
+
);
|
|
67
|
+
console.error('Validação falhou:', errors.join(', '));
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
await addLabel(token, owner, repo, n, LABEL_BUG_APPROVED);
|
|
72
|
+
await commentOnIssue(
|
|
73
|
+
token, owner, repo, n,
|
|
74
|
+
'✅ **bug.md validado.**\n\n' +
|
|
75
|
+
`📄 [\`${fileRel}\`](https://github.com/${owner}/${repo}/blob/main/${fileRel})\n\n` +
|
|
76
|
+
'As seis seções obrigatórias estão presentes — reprodução, causa raiz e teste de ' +
|
|
77
|
+
'regressão inclusive. O bug pode ser aceito na triagem.'
|
|
78
|
+
);
|
|
79
|
+
console.log('bug.md validado.');
|
|
80
|
+
}
|
|
81
|
+
|
|
13
82
|
export async function validate({ issueNumber }) {
|
|
14
83
|
const token = await resolveToken();
|
|
15
84
|
const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
@@ -25,6 +94,13 @@ export async function validate({ issueNumber }) {
|
|
|
25
94
|
}
|
|
26
95
|
|
|
27
96
|
const issue = await getIssue(token, owner, repo, parseInt(issueNumber, 10));
|
|
97
|
+
|
|
98
|
+
// Bug tem artefato próprio (bug.md) e caminho próprio de validação — não passa
|
|
99
|
+
// pelo par spec.md + plan.md, que é exclusivo de Feature.
|
|
100
|
+
if (detectIssueType(issue) === 'Bug') {
|
|
101
|
+
return await validateBug({ token, owner, repo, issue, issueNumber, root });
|
|
102
|
+
}
|
|
103
|
+
|
|
28
104
|
const slug = slugify(issue.title);
|
|
29
105
|
// Ancorado na RAIZ do repo, não no cwd: rodar de um subdiretório encontra o
|
|
30
106
|
// config subindo na árvore e precisa encontrar os documentos no mesmo lugar.
|
|
@@ -57,10 +133,8 @@ export async function validate({ issueNumber }) {
|
|
|
57
133
|
errors.push('❌ `plan.md` não encontrado em `' + `${featureRel}/plan.md` + '`');
|
|
58
134
|
} else {
|
|
59
135
|
const planContent = readFileSync(planPath, 'utf-8');
|
|
60
|
-
for (const section of REQUIRED_PLAN_SECTIONS) {
|
|
61
|
-
|
|
62
|
-
errors.push(`❌ Seção obrigatória ausente no plan.md: **${section}**`);
|
|
63
|
-
}
|
|
136
|
+
for (const section of findMissingSections(planContent, REQUIRED_PLAN_SECTIONS)) {
|
|
137
|
+
errors.push(`❌ Seção obrigatória ausente no plan.md: **${section}**`);
|
|
64
138
|
}
|
|
65
139
|
for (const problem of findIncompleteDocSigns(planContent)) {
|
|
66
140
|
errors.push(`❌ \`plan.md\` parece incompleto: ${problem}`);
|
|
@@ -73,10 +147,8 @@ export async function validate({ issueNumber }) {
|
|
|
73
147
|
errors.push('❌ `spec.md` não encontrado em `' + `${featureRel}/spec.md` + '`');
|
|
74
148
|
} else {
|
|
75
149
|
const specContent = readFileSync(specPath, 'utf-8');
|
|
76
|
-
for (const section of REQUIRED_SPEC_SECTIONS) {
|
|
77
|
-
|
|
78
|
-
errors.push(`❌ Seção obrigatória ausente no spec.md: **${section}**`);
|
|
79
|
-
}
|
|
150
|
+
for (const section of findMissingSections(specContent, REQUIRED_SPEC_SECTIONS)) {
|
|
151
|
+
errors.push(`❌ Seção obrigatória ausente no spec.md: **${section}**`);
|
|
80
152
|
}
|
|
81
153
|
// Seções presentes não garantem documento completo: um corte dentro da
|
|
82
154
|
// última seção passa na checagem acima (foi o caso da EP2-F13).
|