@spec-wave/cli 0.20.0 → 0.23.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/bin/spec-wave.mjs +2 -2
- package/package.json +1 -1
- package/src/agent/anthropic-agent.mjs +3 -1
- package/src/agent/errors.mjs +59 -13
- package/src/agent/openrouter-agent.mjs +5 -1
- package/src/api/github-graphql.mjs +127 -7
- package/src/api/github-rest.mjs +9 -2
- package/src/commands/code-review.mjs +15 -4
- package/src/commands/decompose.mjs +172 -23
- package/src/commands/doctor.mjs +88 -8
- package/src/commands/move.mjs +58 -1
- package/src/commands/order.mjs +200 -7
- package/src/commands/qa.mjs +8 -2
- package/src/commands/repair-stage.mjs +6 -1
- package/src/commands/story.mjs +6 -1
- package/src/commands/task.mjs +6 -1
- package/src/commands/triage.mjs +4 -1
- package/src/commands/validate.mjs +39 -18
- package/src/config.mjs +47 -3
- package/src/lib/board.mjs +87 -3
- package/src/lib/bug-doc.mjs +71 -0
- package/src/lib/claude.mjs +23 -6
- package/src/lib/decomposition-doc.mjs +66 -14
- package/src/lib/dependencies.mjs +14 -4
- package/src/lib/implement-board.mjs +15 -11
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/skills/decompose/SKILL.md +3 -3
- package/src/plugin/skills/order/SKILL.md +8 -4
- package/src/plugin/skills/ready/SKILL.md +1 -1
- package/src/templates/skill/SKILL.md +4 -4
- package/src/templates/workflows/code-review.yml +9 -2
- package/src/templates/workflows/critique.yml +19 -2
- package/src/templates/workflows/decompose.yml +19 -2
- package/src/templates/workflows/generate-bug.yml +19 -2
- package/src/templates/workflows/generate-plan.yml +19 -2
- package/src/templates/workflows/generate-spec.yml +19 -2
- package/src/templates/workflows/qa.yml +5 -1
- package/src/templates/workflows/validate.yml +19 -2
package/src/commands/order.mjs
CHANGED
|
@@ -1,24 +1,176 @@
|
|
|
1
|
-
// Ordena as Stories
|
|
1
|
+
// Ordena as Stories pelas dependências (topológica, Kahn —
|
|
2
2
|
// ver orderStories em src/lib/dependencies.mjs). As dependências vêm de duas
|
|
3
3
|
// fontes, mescladas: a linha "Depende de: #N" no corpo da Story e a relação
|
|
4
4
|
// nativa blocked_by do GitHub.
|
|
5
5
|
//
|
|
6
|
+
// Dependência para FORA da Feature não entra na ordenação (não há como saber
|
|
7
|
+
// onde a Story de outra Feature entra nesta sequência), mas é EXIBIDA: era lida,
|
|
8
|
+
// descartada em silêncio por orderStories e ignorada de novo no aviso de
|
|
9
|
+
// fora-de-ordem. Quem montava o mapa de trabalho de dois devs tinha que
|
|
10
|
+
// reconstruir isso à mão, lendo os decomposition.md.
|
|
11
|
+
//
|
|
12
|
+
// Dois escopos: `order <feature>` (uma Feature) e `order` sem argumento — o
|
|
13
|
+
// mapa de TODAS as Features com trabalho no board, onde as dependências entre
|
|
14
|
+
// Features deixam de ser "externas" e entram no mesmo grafo.
|
|
15
|
+
//
|
|
6
16
|
// Comando LOCAL — rodado pelo dev no terminal. owner/repo vêm da env
|
|
7
17
|
// GITHUB_REPOSITORY quando existir, senão do .spec-wave.json (gravado pelo init).
|
|
8
18
|
import * as p from '@clack/prompts';
|
|
9
19
|
import chalk from 'chalk';
|
|
10
20
|
import { resolveToken } from '../api/auth.mjs';
|
|
11
21
|
import { getIssue, listBlockedBy } from '../api/github-rest.mjs';
|
|
12
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
addProjectItem, listSubIssues, getItemSingleSelectValue, listProjectItems,
|
|
24
|
+
} from '../api/github-graphql.mjs';
|
|
13
25
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
14
26
|
import { parseDependencies, orderStories } from '../lib/dependencies.mjs';
|
|
15
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
loadProjectConfig, resolveField, indexBoardItems, selectOpenFeatures,
|
|
29
|
+
} from '../lib/board.mjs';
|
|
16
30
|
import { resolveRepoContext } from '../lib/project-root.mjs';
|
|
17
31
|
import { CONFIG_FILE, STAGE_ORDER, STAGE_DEVELOPMENT, STAGE_DONE } from '../config.mjs';
|
|
18
32
|
|
|
19
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Rótulo curto de uma Feature para a coluna do mapa (função PURA).
|
|
35
|
+
*/
|
|
36
|
+
export function featureLabel(feature) {
|
|
37
|
+
const titulo = String(feature?.title || '').replace(/^\s*\[FEATURE\]\s*/i, '').trim();
|
|
38
|
+
const curto = titulo.length > 28 ? `${titulo.slice(0, 27)}…` : titulo;
|
|
39
|
+
return `#${feature?.number} ${curto}`.trim();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* O mapa de execução de várias Features (função PURA).
|
|
44
|
+
*
|
|
45
|
+
* Uma ordem SÓ, não uma lista por Feature: é isso que responde "o que os dois
|
|
46
|
+
* devs pegam agora" — a pergunta que obrigava a abrir nove decomposition.md e
|
|
47
|
+
* reconstruir o grafo à mão. A coluna da Feature vem junto porque, num grafo de
|
|
48
|
+
* várias, saber a que Feature cada Story pertence é metade da informação.
|
|
49
|
+
*
|
|
50
|
+
* @param {object} params
|
|
51
|
+
* @param {number[]} params.sorted ordem topológica (números de Story)
|
|
52
|
+
* @param {Map<number, {title:string, dependsOn:number[]}>} params.byNumber
|
|
53
|
+
* @param {Map<number, object>} params.featureOf Story → Feature dona
|
|
54
|
+
* @param {Map<number, string|null>} params.stageOf Story → Etapa
|
|
55
|
+
* @returns {string}
|
|
56
|
+
*/
|
|
57
|
+
export function renderBoardOrder({ sorted, byNumber, featureOf, stageOf }) {
|
|
58
|
+
return (sorted || []).map((n, i) => {
|
|
59
|
+
const s = byNumber.get(n);
|
|
60
|
+
const etapa = stageOf.get(n) || '—';
|
|
61
|
+
const deps = (s?.dependsOn || []).filter(d => byNumber.has(d));
|
|
62
|
+
const dep = deps.length > 0 ? ` ← depende de ${deps.map(d => `#${d}`).join(', ')}` : '';
|
|
63
|
+
return `${String(i + 1).padStart(2)}. #${n} ${s?.title || ''}\n` +
|
|
64
|
+
` ${featureLabel(featureOf.get(n))} · Etapa: ${etapa}${dep}`;
|
|
65
|
+
}).join('\n');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* `spec-wave order` sem argumento: o grafo de TODAS as Features com trabalho.
|
|
70
|
+
*
|
|
71
|
+
* Fonte do conjunto é o BOARD, não os arquivos — `decomposition.md` registra o
|
|
72
|
+
* que foi proposto, a issue é o que existe. Uma query paginada de itens do
|
|
73
|
+
* project resolve Etapa e tipo de todo mundo de uma vez, no lugar do laço de
|
|
74
|
+
* duas chamadas por Story que o modo de uma Feature ainda usa.
|
|
75
|
+
*/
|
|
76
|
+
async function orderBoard({ token, owner, repo }) {
|
|
77
|
+
const { project, error: projectError } = loadProjectConfig();
|
|
78
|
+
if (projectError) {
|
|
79
|
+
p.log.error(
|
|
80
|
+
`${projectError} — sem board não há como saber quais Features têm trabalho. ` +
|
|
81
|
+
'Use `spec-wave order <feature>` para ordenar uma Feature específica.'
|
|
82
|
+
);
|
|
83
|
+
process.exitCode = 1;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let itens;
|
|
88
|
+
try {
|
|
89
|
+
itens = await listProjectItems(token, project.id);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
p.log.error(`Não foi possível ler os itens do Project: ${err.message}`);
|
|
92
|
+
process.exitCode = 1;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const indice = indexBoardItems(itens);
|
|
96
|
+
const features = selectOpenFeatures(itens);
|
|
97
|
+
if (features.length === 0) {
|
|
98
|
+
p.log.info(`Nenhuma Feature aberta fora de "${STAGE_DONE}" no board — nada a ordenar.`);
|
|
99
|
+
p.outro('Nada a fazer.');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const enriched = [];
|
|
104
|
+
const featureOf = new Map();
|
|
105
|
+
const stageOf = new Map();
|
|
106
|
+
const semStories = [];
|
|
107
|
+
for (const feature of features) {
|
|
108
|
+
const subs = await listSubIssues(token, feature.nodeId).catch(() => []);
|
|
109
|
+
const stories = subs.filter(sub =>
|
|
110
|
+
detectIssueType({ title: sub.title, labels: sub.labels }) === 'Story');
|
|
111
|
+
if (stories.length === 0) { semStories.push(feature); continue; }
|
|
112
|
+
for (const story of stories) {
|
|
113
|
+
// Story concluída não entra: o mapa é do que falta fazer.
|
|
114
|
+
if (indice.get(story.number)?.fields?.Etapa === STAGE_DONE) continue;
|
|
115
|
+
const fromBody = parseDependencies(story.body);
|
|
116
|
+
const fromBlockedBy = (await listBlockedBy(token, owner, repo, story.number).catch(() => []))
|
|
117
|
+
.map(b => b.number);
|
|
118
|
+
enriched.push({
|
|
119
|
+
number: story.number,
|
|
120
|
+
title: story.title,
|
|
121
|
+
dependsOn: [...new Set([...fromBody, ...fromBlockedBy])],
|
|
122
|
+
});
|
|
123
|
+
featureOf.set(story.number, feature);
|
|
124
|
+
stageOf.set(story.number, indice.get(story.number)?.fields?.Etapa || null);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (enriched.length === 0) {
|
|
129
|
+
p.log.info(`${features.length} Feature(s) aberta(s), nenhuma com Story pendente.`);
|
|
130
|
+
p.outro('Nada a fazer.');
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const byNumber = new Map(enriched.map(s => [s.number, s]));
|
|
135
|
+
const { order: sorted, cycle, external } = orderStories(
|
|
136
|
+
enriched.map(({ number, dependsOn }) => ({ number, dependsOn })));
|
|
137
|
+
|
|
138
|
+
if (cycle.length > 0) {
|
|
139
|
+
p.log.warn(
|
|
140
|
+
chalk.yellow.bold('⚠ CICLO DE DEPENDÊNCIAS detectado!') + '\n' +
|
|
141
|
+
`Stories envolvidas (ou bloqueadas pelo ciclo): ${cycle.map(n => `#${n}`).join(', ')}.\n` +
|
|
142
|
+
'Elas ficaram fora da ordem abaixo. Num grafo de várias Features o ciclo pode ' +
|
|
143
|
+
'ATRAVESSAR a fronteira delas — confira as linhas "Depende de" dos dois lados.'
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
p.note(
|
|
148
|
+
renderBoardOrder({ sorted, byNumber, featureOf, stageOf }),
|
|
149
|
+
`Ordem de execução — ${sorted.length} Story(ies) de ${features.length} Feature(s)`
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// Aqui "fora do conjunto" não é mais "de outra Feature" (essas agora estão
|
|
153
|
+
// DENTRO): é Story já concluída, Feature em Done ou issue de outro board.
|
|
154
|
+
if (external.size > 0) {
|
|
155
|
+
const linhas = [...external.entries()].map(([n, deps]) =>
|
|
156
|
+
` #${n} ← ${deps.map(d => `#${d}`).join(', ')}`);
|
|
157
|
+
p.note(
|
|
158
|
+
linhas.join('\n'),
|
|
159
|
+
'Dependências fora do conjunto (concluídas, de Feature em Done ou de outro board)'
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (semStories.length > 0) {
|
|
164
|
+
p.log.info(`Sem Stories (ainda não decompostas): ${semStories.map(f => `#${f.number}`).join(', ')}.`);
|
|
165
|
+
}
|
|
166
|
+
p.outro(`${chalk.green('✓')} ${sorted.length} de ${enriched.length} story(ies) ordenada(s).`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function order({ feature: featureArg } = {}) {
|
|
170
|
+
const semArgumento = featureArg === undefined || featureArg === null
|
|
171
|
+
|| String(featureArg).trim() === '';
|
|
20
172
|
const featureNumber = parseInt(String(featureArg).replace('#', ''), 10);
|
|
21
|
-
if (!Number.isInteger(featureNumber) || featureNumber <= 0) {
|
|
173
|
+
if (!semArgumento && (!Number.isInteger(featureNumber) || featureNumber <= 0)) {
|
|
22
174
|
p.log.error(`Feature inválida: "${featureArg}". Use o número da issue, ex.: 12 ou #12.`);
|
|
23
175
|
process.exitCode = 1;
|
|
24
176
|
return;
|
|
@@ -43,6 +195,11 @@ export async function order({ feature: featureArg }) {
|
|
|
43
195
|
return;
|
|
44
196
|
}
|
|
45
197
|
|
|
198
|
+
if (semArgumento) {
|
|
199
|
+
p.intro(chalk.bold('spec-wave order — todas as Features com trabalho'));
|
|
200
|
+
return await orderBoard({ token, owner, repo });
|
|
201
|
+
}
|
|
202
|
+
|
|
46
203
|
p.intro(chalk.bold(`spec-wave order #${featureNumber}`));
|
|
47
204
|
|
|
48
205
|
// 1. Lê a Feature e valida o tipo.
|
|
@@ -116,7 +273,19 @@ export async function order({ feature: featureArg }) {
|
|
|
116
273
|
|
|
117
274
|
// 5. Ordenação topológica (nunca lança; ciclo vem em `cycle`).
|
|
118
275
|
const byNumber = new Map(enriched.map(s => [s.number, s]));
|
|
119
|
-
const { order: sorted, cycle } = orderStories(
|
|
276
|
+
const { order: sorted, cycle, external } = orderStories(
|
|
277
|
+
enriched.map(({ number, dependsOn }) => ({ number, dependsOn })));
|
|
278
|
+
|
|
279
|
+
// Uma leitura por issue externa DISTINTA — o estado dela é o que diz se o
|
|
280
|
+
// bloqueio ainda vale.
|
|
281
|
+
const foraDoConjunto = [...new Set([...external.values()].flat())];
|
|
282
|
+
const externasInfo = new Map();
|
|
283
|
+
await Promise.all(foraDoConjunto.map(async (n) => {
|
|
284
|
+
const issue = await getIssue(token, owner, repo, n).catch(() => null);
|
|
285
|
+
externasInfo.set(n, issue
|
|
286
|
+
? { title: issue.title, state: issue.state, aberta: issue.state === 'open' }
|
|
287
|
+
: { title: '(não foi possível ler)', state: null, aberta: true });
|
|
288
|
+
}));
|
|
120
289
|
|
|
121
290
|
if (cycle.length > 0) {
|
|
122
291
|
p.log.warn(
|
|
@@ -136,6 +305,18 @@ export async function order({ feature: featureArg }) {
|
|
|
136
305
|
};
|
|
137
306
|
p.note(sorted.map(line).join('\n'), `Ordem de execução das Stories da Feature #${featureNumber}`);
|
|
138
307
|
|
|
308
|
+
if (external.size > 0) {
|
|
309
|
+
const linhas = [...external.entries()].map(([n, deps]) => {
|
|
310
|
+
const detalhe = deps.map(d => {
|
|
311
|
+
const info = externasInfo.get(d);
|
|
312
|
+
const marca = info?.aberta ? chalk.yellow('aberta') : chalk.green('fechada');
|
|
313
|
+
return `#${d} (${marca}) ${info?.title || ''}`.trim();
|
|
314
|
+
}).join('\n ');
|
|
315
|
+
return ` #${n} ${byNumber.get(n)?.title || ''}\n ← ${detalhe}`;
|
|
316
|
+
});
|
|
317
|
+
p.note(linhas.join('\n'), 'Bloqueadas por fora desta Feature');
|
|
318
|
+
}
|
|
319
|
+
|
|
139
320
|
// 6. Aviso final: dependente já em Desenvolvimento+ com dependência não-Done.
|
|
140
321
|
const devIdx = STAGE_ORDER.indexOf(STAGE_DEVELOPMENT);
|
|
141
322
|
const outOfOrder = [];
|
|
@@ -144,7 +325,19 @@ export async function order({ feature: featureArg }) {
|
|
|
144
325
|
const idx = stage ? STAGE_ORDER.indexOf(stage) : -1;
|
|
145
326
|
if (idx === -1 || idx < devIdx) continue; // ainda não chegou em Desenvolvimento
|
|
146
327
|
for (const d of s.dependsOn) {
|
|
147
|
-
if (!byNumber.has(d))
|
|
328
|
+
if (!byNumber.has(d)) {
|
|
329
|
+
// Externa: não tem Etapa neste conjunto, mas tem ESTADO — e uma issue
|
|
330
|
+
// aberta bloqueando quem já está em Desenvolvimento é a mesma falha que
|
|
331
|
+
// o aviso abaixo denuncia, só que atravessando a fronteira.
|
|
332
|
+
const info = externasInfo.get(d);
|
|
333
|
+
if (info?.aberta) {
|
|
334
|
+
outOfOrder.push(
|
|
335
|
+
`#${s.number} já está em "${stage}", mas depende de #${d}, de outra Feature, ` +
|
|
336
|
+
'que continua ABERTA.'
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
148
341
|
const depStage = stageOf.get(d);
|
|
149
342
|
if (depStage !== STAGE_DONE) {
|
|
150
343
|
outOfOrder.push(
|
package/src/commands/qa.mjs
CHANGED
|
@@ -73,6 +73,9 @@ export async function qa({ prNumber }) {
|
|
|
73
73
|
|
|
74
74
|
const etapaField = await resolveField(projectToken, project, 'Etapa').catch(() => null);
|
|
75
75
|
const statusField = await resolveField(projectToken, project, 'Status').catch(() => null);
|
|
76
|
+
// Repara o Work Item Type vazio de passagem (o apply nunca o escreveu).
|
|
77
|
+
// Só preenche o vazio e nunca derruba o movimento — ver ensureWorkItemType.
|
|
78
|
+
const typeField = await resolveField(projectToken, project, 'Work Item Type').catch(() => null);
|
|
76
79
|
|
|
77
80
|
const seen = new Set();
|
|
78
81
|
const updated = [];
|
|
@@ -86,7 +89,8 @@ export async function qa({ prNumber }) {
|
|
|
86
89
|
seen.add(issue.number);
|
|
87
90
|
try {
|
|
88
91
|
const moved = await advanceToStage(
|
|
89
|
-
projectToken, project, etapaField, statusField, issue.node_id, QA_STAGE, TODO_STATUS
|
|
92
|
+
projectToken, project, etapaField, statusField, issue.node_id, QA_STAGE, TODO_STATUS,
|
|
93
|
+
{ typeField, itemType: 'Bug' });
|
|
90
94
|
if (moved) {
|
|
91
95
|
updated.push(`#${issue.number} ${issue.title} (bug)`);
|
|
92
96
|
console.log(`Bug #${issue.number} → "${QA_STAGE}" / Status "${TODO_STATUS}".`);
|
|
@@ -104,7 +108,9 @@ export async function qa({ prNumber }) {
|
|
|
104
108
|
seen.add(feature.number);
|
|
105
109
|
try {
|
|
106
110
|
// Avança para "🧪 QA" e reinicia o Status em "Todo" (nunca retrocede).
|
|
107
|
-
const moved = await advanceToStage(
|
|
111
|
+
const moved = await advanceToStage(
|
|
112
|
+
projectToken, project, etapaField, statusField, feature.node_id, QA_STAGE, TODO_STATUS,
|
|
113
|
+
{ typeField, itemType: 'Feature' });
|
|
108
114
|
if (moved) {
|
|
109
115
|
updated.push(`#${feature.number} ${feature.title}`);
|
|
110
116
|
console.log(`Feature #${feature.number} → "${QA_STAGE}" / Status "${TODO_STATUS}".`);
|
|
@@ -164,6 +164,10 @@ export async function repairStage(issuesArg, stageArg, options = {}) {
|
|
|
164
164
|
}
|
|
165
165
|
const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
|
|
166
166
|
const statusField = status ? await resolveField(token, project, 'Status').catch(() => null) : null;
|
|
167
|
+
// Reparar a Etapa e deixar o item sem tipo seria consertar metade: os mesmos
|
|
168
|
+
// itens que perderam a Etapa nasceram do apply, que também não escrevia o
|
|
169
|
+
// Work Item Type. Só preenche o vazio (ver ensureWorkItemType).
|
|
170
|
+
const typeField = await resolveField(token, project, 'Work Item Type').catch(() => null);
|
|
167
171
|
|
|
168
172
|
// Lê tudo ANTES de escrever qualquer coisa: reparo parcial por issue
|
|
169
173
|
// inexistente no meio da lista é o tipo de surpresa que ninguém quer aqui.
|
|
@@ -216,7 +220,8 @@ export async function repairStage(issuesArg, stageArg, options = {}) {
|
|
|
216
220
|
for (const alvo of alvos) {
|
|
217
221
|
try {
|
|
218
222
|
const { from } = await setItemStage(
|
|
219
|
-
token, project, etapaField, statusField, alvo.issue.node_id, stage, status
|
|
223
|
+
token, project, etapaField, statusField, alvo.issue.node_id, stage, status,
|
|
224
|
+
{ typeField, itemType: alvo.type });
|
|
220
225
|
reparados += 1;
|
|
221
226
|
p.log.success(`#${alvo.number}: ${from || '(sem etapa)'} → ${chalk.bold(stage)}${status ? ` / ${status}` : ''}`);
|
|
222
227
|
await commentOnIssue(token, owner, repo, alvo.number, renderRepairComment({
|
package/src/commands/story.mjs
CHANGED
|
@@ -81,11 +81,16 @@ export async function story({ action, issue: issueArg }) {
|
|
|
81
81
|
}
|
|
82
82
|
const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
|
|
83
83
|
const statusField = await resolveField(token, project, 'Status').catch(() => null);
|
|
84
|
+
// Repara o Work Item Type vazio de passagem (o apply nunca o escreveu).
|
|
85
|
+
// Só preenche o vazio e nunca derruba o movimento — ver ensureWorkItemType.
|
|
86
|
+
const typeField = await resolveField(token, project, 'Work Item Type').catch(() => null);
|
|
84
87
|
|
|
85
88
|
// 3. Avança para Code Review (Status reinicia em Todo ao trocar de etapa).
|
|
86
89
|
let moved;
|
|
87
90
|
try {
|
|
88
|
-
moved = await advanceToStage(
|
|
91
|
+
moved = await advanceToStage(
|
|
92
|
+
token, project, etapaField, statusField, issue.node_id, STAGE_CODE_REVIEW, PROGRESS_TODO,
|
|
93
|
+
{ typeField, itemType: type });
|
|
89
94
|
} catch (err) {
|
|
90
95
|
p.log.error(`Falha ao atualizar o board: ${err.message}`);
|
|
91
96
|
process.exitCode = 1;
|
package/src/commands/task.mjs
CHANGED
|
@@ -102,6 +102,9 @@ export async function task({ action, issue: issueArg }) {
|
|
|
102
102
|
}
|
|
103
103
|
const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
|
|
104
104
|
const statusField = await resolveField(token, project, 'Status').catch(() => null);
|
|
105
|
+
// Repara o Work Item Type vazio de passagem (o apply nunca o escreveu).
|
|
106
|
+
// Só preenche o vazio e nunca derruba o movimento — ver ensureWorkItemType.
|
|
107
|
+
const typeField = await resolveField(token, project, 'Work Item Type').catch(() => null);
|
|
105
108
|
|
|
106
109
|
// 3. start: garante que nenhuma task irmã (mesma Story) está In Progress.
|
|
107
110
|
if (action === 'start') {
|
|
@@ -146,7 +149,9 @@ export async function task({ action, issue: issueArg }) {
|
|
|
146
149
|
|
|
147
150
|
let moved;
|
|
148
151
|
try {
|
|
149
|
-
moved = await advanceToStage(
|
|
152
|
+
moved = await advanceToStage(
|
|
153
|
+
token, project, etapaField, statusField, issue.node_id, target.stage, target.status,
|
|
154
|
+
{ typeField, itemType: type });
|
|
150
155
|
} catch (err) {
|
|
151
156
|
p.log.error(`Falha ao atualizar o board: ${err.message}`);
|
|
152
157
|
process.exitCode = 1;
|
package/src/commands/triage.mjs
CHANGED
|
@@ -163,8 +163,11 @@ async function moveToReady({ token, issueNumber, issue, root }) {
|
|
|
163
163
|
try {
|
|
164
164
|
const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
|
|
165
165
|
const statusField = await resolveField(token, project, 'Status').catch(() => null);
|
|
166
|
+
// Triagem só trata Bug — o tipo aqui é certeza, não inferência de título.
|
|
167
|
+
const typeField = await resolveField(token, project, 'Work Item Type').catch(() => null);
|
|
166
168
|
const moved = await advanceToStage(
|
|
167
|
-
token, project, etapaField, statusField, issue.node_id, STAGE_READY, PROGRESS_TODO
|
|
169
|
+
token, project, etapaField, statusField, issue.node_id, STAGE_READY, PROGRESS_TODO,
|
|
170
|
+
{ typeField, itemType: 'Bug' });
|
|
168
171
|
if (!moved) {
|
|
169
172
|
p.log.info(`#${issueNumber} já está em ${STAGE_READY} ou etapa posterior — mantido.`);
|
|
170
173
|
}
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
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,
|
|
11
|
+
import { bugDocPaths, describeMissingSections } from '../lib/bug-doc.mjs';
|
|
12
12
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
13
13
|
import { loadConfig } from '../lib/project-root.mjs';
|
|
14
14
|
|
|
@@ -47,8 +47,8 @@ async function validateBug({ token, owner, repo, issue, issueNumber, root }) {
|
|
|
47
47
|
);
|
|
48
48
|
} else {
|
|
49
49
|
const content = readFileSync(fileAbs, 'utf-8');
|
|
50
|
-
for (const
|
|
51
|
-
errors.push(
|
|
50
|
+
for (const faltante of describeMissingSections(content, REQUIRED_BUG_SECTIONS)) {
|
|
51
|
+
errors.push(renderMissingSection('bug.md', faltante));
|
|
52
52
|
}
|
|
53
53
|
for (const problem of findIncompleteDocSigns(content)) {
|
|
54
54
|
errors.push(`❌ \`bug.md\` parece incompleto: ${problem}`);
|
|
@@ -79,6 +79,22 @@ async function validateBug({ token, owner, repo, issue, issueNumber, root }) {
|
|
|
79
79
|
console.log('bug.md validado.');
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Linha de erro de seção ausente (função PURA).
|
|
84
|
+
*
|
|
85
|
+
* Dizer só o que falta obriga o humano a comparar título a título; dizer o que
|
|
86
|
+
* EXISTE no lugar resolve em segundos o caso real (um "# Rollout e
|
|
87
|
+
* Monitoramento" onde se esperava "# Rollback e Monitoramento", com o conteúdo
|
|
88
|
+
* certo embaixo).
|
|
89
|
+
*/
|
|
90
|
+
export function renderMissingSection(doc, { section, found }) {
|
|
91
|
+
const base = `❌ Seção obrigatória ausente no ${doc}: **${section}**`;
|
|
92
|
+
return found
|
|
93
|
+
? `${base} — encontrei \`# ${found}\`, esperava \`# ${section}\`. ` +
|
|
94
|
+
'Se o conteúdo é o certo, basta renomear o título.'
|
|
95
|
+
: base;
|
|
96
|
+
}
|
|
97
|
+
|
|
82
98
|
export async function validate({ issueNumber }) {
|
|
83
99
|
const token = await resolveToken();
|
|
84
100
|
const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
@@ -133,8 +149,8 @@ export async function validate({ issueNumber }) {
|
|
|
133
149
|
errors.push('❌ `plan.md` não encontrado em `' + `${featureRel}/plan.md` + '`');
|
|
134
150
|
} else {
|
|
135
151
|
const planContent = readFileSync(planPath, 'utf-8');
|
|
136
|
-
for (const
|
|
137
|
-
errors.push(
|
|
152
|
+
for (const faltante of describeMissingSections(planContent, REQUIRED_PLAN_SECTIONS)) {
|
|
153
|
+
errors.push(renderMissingSection('plan.md', faltante));
|
|
138
154
|
}
|
|
139
155
|
for (const problem of findIncompleteDocSigns(planContent)) {
|
|
140
156
|
errors.push(`❌ \`plan.md\` parece incompleto: ${problem}`);
|
|
@@ -147,8 +163,8 @@ export async function validate({ issueNumber }) {
|
|
|
147
163
|
errors.push('❌ `spec.md` não encontrado em `' + `${featureRel}/spec.md` + '`');
|
|
148
164
|
} else {
|
|
149
165
|
const specContent = readFileSync(specPath, 'utf-8');
|
|
150
|
-
for (const
|
|
151
|
-
errors.push(
|
|
166
|
+
for (const faltante of describeMissingSections(specContent, REQUIRED_SPEC_SECTIONS)) {
|
|
167
|
+
errors.push(renderMissingSection('spec.md', faltante));
|
|
152
168
|
}
|
|
153
169
|
// Seções presentes não garantem documento completo: um corte dentro da
|
|
154
170
|
// última seção passa na checagem acima (foi o caso da EP2-F13).
|
|
@@ -165,18 +181,23 @@ export async function validate({ issueNumber }) {
|
|
|
165
181
|
token, owner, repo, parseInt(issueNumber, 10),
|
|
166
182
|
`⚠️ **Validação falhou — Feature não está pronta.**\n\n` +
|
|
167
183
|
errors.join('\n') +
|
|
168
|
-
`\n\
|
|
184
|
+
`\n\nCorrija os problemas e adicione novamente a label \`spec-wave:ready\`.` +
|
|
185
|
+
`\n\nSe os documentos precisam mesmo ser REGERADOS (e não só corrigidos), aplique ` +
|
|
186
|
+
`você a label \`spec-wave:spec\` — ela **sobrescreve** o \`spec.md\`, inclusive o que ` +
|
|
187
|
+
'foi revisado à mão.'
|
|
169
188
|
);
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
189
|
+
// A reprova NÃO aplica mais `spec-wave:spec` sozinha.
|
|
190
|
+
//
|
|
191
|
+
// `spec-wave:spec` é label de GATILHO: aplicá-la manda o generate-spec
|
|
192
|
+
// sobrescrever o spec.md. Uma reprova por título trocado ("# Rollout" onde
|
|
193
|
+
// se esperava "# Rollback", com o conteúdo certo embaixo) apagaria uma spec
|
|
194
|
+
// revisada à mão por causa de uma palavra. Isso não aconteceu até hoje por
|
|
195
|
+
// um acidente: o GitHub não dispara `labeled` para eventos do GITHUB_TOKEN,
|
|
196
|
+
// então a label ficava inerte. Quem trocasse por um PAT — o que a própria
|
|
197
|
+
// documentação sugere para alcançar Projects de organização — perderia o
|
|
198
|
+
// documento sem nunca ter pedido isso.
|
|
199
|
+
//
|
|
200
|
+
// Regenerar é decisão humana, e o comentário acima diz como e o que custa.
|
|
180
201
|
console.error('Validação falhou:', errors.join(', '));
|
|
181
202
|
process.exit(1);
|
|
182
203
|
}
|
package/src/config.mjs
CHANGED
|
@@ -43,6 +43,44 @@ export const AI_ACTIONS = ['spec', 'plan', 'decompose', 'critique', 'bug'];
|
|
|
43
43
|
// repositório inteiro. Resolvido por resolveModelLabel() em src/lib/claude.mjs.
|
|
44
44
|
export const MODEL_LABEL_PREFIX = 'spec-wave:model:';
|
|
45
45
|
|
|
46
|
+
// Apelidos de modelo SUGERIDOS pelo `doctor` quando o repo não tem
|
|
47
|
+
// `ai.modelAliases` (ou tem só parte deles). São os mesmos que o repo do
|
|
48
|
+
// spec-wave usa: um por família de modelo, para que a label
|
|
49
|
+
// `spec-wave:model:<apelido>` cubra "roda de novo num modelo mais forte" sem
|
|
50
|
+
// obrigar cada projeto a inventar a própria nomenclatura.
|
|
51
|
+
//
|
|
52
|
+
// O mapa é POR PROVIDER porque o formato do id não é o mesmo: a OpenRouter usa
|
|
53
|
+
// slug com "/" (anthropic/claude-opus-5) e a API da Anthropic usa o id nu
|
|
54
|
+
// (claude-opus-5). Sugerir o formato errado geraria exatamente o aviso de
|
|
55
|
+
// "apelido com formato incompatível" que o doctor emite na linha seguinte.
|
|
56
|
+
export const RECOMMENDED_MODEL_ALIASES = {
|
|
57
|
+
openrouter: {
|
|
58
|
+
opus: 'anthropic/claude-opus-5',
|
|
59
|
+
sonnet: 'anthropic/claude-sonnet-5',
|
|
60
|
+
haiku: 'anthropic/claude-haiku-4.5',
|
|
61
|
+
glm: 'z-ai/glm-5.2',
|
|
62
|
+
'kimi-k3': 'moonshotai/kimi-k3',
|
|
63
|
+
'gpt-sol': 'openai/gpt-5.6-sol',
|
|
64
|
+
'gpt-luna': 'openai/gpt-5.6-luna',
|
|
65
|
+
},
|
|
66
|
+
// Só os modelos da própria Anthropic — os demais não existem nessa API.
|
|
67
|
+
anthropic: {
|
|
68
|
+
opus: 'claude-opus-5',
|
|
69
|
+
sonnet: 'claude-sonnet-5',
|
|
70
|
+
haiku: 'claude-haiku-4-5',
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Apelidos recomendados para um provider (função PURA).
|
|
76
|
+
*
|
|
77
|
+
* @param {string} [provider] valor de `ai.provider`
|
|
78
|
+
* @returns {Record<string,string>}
|
|
79
|
+
*/
|
|
80
|
+
export function recommendedModelAliases(provider) {
|
|
81
|
+
return RECOMMENDED_MODEL_ALIASES[provider] || RECOMMENDED_MODEL_ALIASES[DEFAULT_PROVIDER];
|
|
82
|
+
}
|
|
83
|
+
|
|
46
84
|
// Quantas críticas seguidas podem reprovar a mesma issue antes de exigir revisão
|
|
47
85
|
// humana. Na última tentativa a IA NÃO é chamada: o fluxo aplica
|
|
48
86
|
// `spec-wave:needs-human` e para. Ajustável por `ai.maxCritiqueAttempts`.
|
|
@@ -334,6 +372,12 @@ export const LABEL_CRITIQUE_FAILED = 'spec-wave:critique-failed';
|
|
|
334
372
|
export const LABEL_DECOMPOSED = 'spec-wave:decomposed';
|
|
335
373
|
export const LABEL_DECOMPOSE_READY = 'spec-wave:decompose-ready';
|
|
336
374
|
export const LABEL_NEEDS_HUMAN = 'spec-wave:needs-human';
|
|
375
|
+
// Selo de que spec+plan passaram pelo `validate`. É o que distingue uma Feature
|
|
376
|
+
// pronta de uma que só foi movida à mão para ✅ Ready.
|
|
377
|
+
export const LABEL_PLAN_APPROVED = 'spec-wave:plan-approved';
|
|
378
|
+
// Gatilhos que REGERAM documento: presentes na issue, indicam etapa pendente.
|
|
379
|
+
export const LABEL_SPEC = 'spec-wave:spec';
|
|
380
|
+
export const LABEL_PLAN = 'spec-wave:plan';
|
|
337
381
|
// Achado GRAVE que o Tech Leader aceitou como risco conhecido. Fica na issue
|
|
338
382
|
// depois que a crítica passa: sem ela, o risco aceito some da vista assim que a
|
|
339
383
|
// rodada seguinte roda limpa, e ninguém mais sabe que houve uma decisão.
|
|
@@ -344,11 +388,11 @@ export const LABEL_RISK_ACCEPTED = 'spec-wave:risk-accepted';
|
|
|
344
388
|
export const LABEL_CRITIQUE = 'spec-wave:critique';
|
|
345
389
|
|
|
346
390
|
export const TRIGGER_LABELS = [
|
|
347
|
-
{ name:
|
|
348
|
-
{ name:
|
|
391
|
+
{ name: LABEL_SPEC, color: 'BFD4F2', description: 'Gerar spec.md via GitHub Action' },
|
|
392
|
+
{ name: LABEL_PLAN, color: 'BFD4F2', description: 'Gerar plan.md via GitHub Action' },
|
|
349
393
|
{ name: LABEL_CRITIQUE, color: 'BFD4F2', description: 'Re-criticar o plan.md COMO ESTÁ, sem regerar' },
|
|
350
394
|
{ name: 'spec-wave:ready', color: '0E8A16', description: 'Validar spec+plan e mover para Ready' },
|
|
351
|
-
{ name:
|
|
395
|
+
{ name: LABEL_PLAN_APPROVED, color: '0E8A16', description: 'Spec+plan validados com sucesso' },
|
|
352
396
|
{ name: LABEL_DECOMPOSE, color: 'BFD4F2', description: 'Gerar/re-criticar o rascunho da decomposição (decomposition.md)' },
|
|
353
397
|
{ name: LABEL_DECOMPOSE_APPLY, color: 'BFD4F2', description: 'Aplicar o decomposition.md revisado: criar Stories e Tasks' },
|
|
354
398
|
{ name: LABEL_DEV_AGENT, color: '5319E7', description: 'Enfileira a issue para o dev-agent autônomo' },
|