@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/lib/board.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Extraídos de code-review.mjs/qa.mjs para uso também pelos comandos de CLI
|
|
3
3
|
// (task/story/order). Ver a distinção Etapa × Status em config.mjs.
|
|
4
4
|
import { addProjectItem, setItemSingleSelect, getSingleSelectField, getItemSingleSelectValue } from '../api/github-graphql.mjs';
|
|
5
|
-
import { CONFIG_FILE, STAGE_ORDER, STATUS_OPTIONS } from '../config.mjs';
|
|
5
|
+
import { CONFIG_FILE, STAGE_ORDER, STATUS_OPTIONS, WORK_ITEM_TYPES, STAGE_DONE } from '../config.mjs';
|
|
6
6
|
import { loadConfig } from './project-root.mjs';
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -116,6 +116,78 @@ export function resolveStageName(input) {
|
|
|
116
116
|
return { stage: null, error: `Etapa "${input}" não existe. Use uma destas: ${list()}.` };
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Índice do board por número de issue (função PURA).
|
|
121
|
+
*
|
|
122
|
+
* @param {Array<{number:number}>} items saída de listProjectItems
|
|
123
|
+
* @returns {Map<number, object>}
|
|
124
|
+
*/
|
|
125
|
+
export function indexBoardItems(items) {
|
|
126
|
+
return new Map((items || []).filter(i => i?.number).map(i => [i.number, i]));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* As Features que ainda têm trabalho (função PURA).
|
|
131
|
+
*
|
|
132
|
+
* "Ainda tem trabalho" = issue aberta e Etapa diferente de 🎉 Done. É este o
|
|
133
|
+
* conjunto do `order` sem argumento: o mapa de quem depende de quem quando o
|
|
134
|
+
* trabalho de uma onda inteira está espalhado por várias Features.
|
|
135
|
+
*
|
|
136
|
+
* O tipo sai do campo "Work Item Type", com FALLBACK no prefixo do título: o
|
|
137
|
+
* campo ficou vazio em todo item criado pelo apply até a v0.21.0, e um board com
|
|
138
|
+
* esse buraco não pode virar um mapa vazio em silêncio.
|
|
139
|
+
*
|
|
140
|
+
* @param {Array<object>} items saída de listProjectItems
|
|
141
|
+
* @returns {Array<object>} Features, em ordem crescente de número
|
|
142
|
+
*/
|
|
143
|
+
export function selectOpenFeatures(items) {
|
|
144
|
+
return (items || [])
|
|
145
|
+
.filter(i => i?.number
|
|
146
|
+
&& String(i.state || '').toUpperCase() !== 'CLOSED'
|
|
147
|
+
&& (i.fields?.['Work Item Type'] === 'Feature' || /^\s*\[FEATURE\]/i.test(i.title || ''))
|
|
148
|
+
&& i.fields?.Etapa !== STAGE_DONE)
|
|
149
|
+
.sort((a, b) => a.number - b.number);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Preenche o "Work Item Type" do item quando ele está VAZIO (best-effort).
|
|
154
|
+
*
|
|
155
|
+
* O campo existe no board desde o `init`, mas só o comando `issue` o escrevia:
|
|
156
|
+
* toda issue nascida do `decompose --apply` (dezenas por Feature) e todo item
|
|
157
|
+
* que entrou no board por outro caminho ficavam com o tipo em branco, e as
|
|
158
|
+
* telas que agrupam por Work Item Type os perdiam. Não é configuração faltando:
|
|
159
|
+
* os mesmos ids, project e token escrevem o campo sem falha quando alguém o faz
|
|
160
|
+
* à mão. O `move` também não o reparava, então nem passar pelo fluxo corrigia.
|
|
161
|
+
*
|
|
162
|
+
* Escreve só no vazio, de propósito: o tipo do título ("[STORY] …") é uma
|
|
163
|
+
* inferência, e sobrescrever um valor que um humano ajustou no board seria
|
|
164
|
+
* trocar um dado bom por um palpite. Por isso também roda ANTES do guard de
|
|
165
|
+
* avanço de Etapa — item já adiante não avança, mas continua merecendo o reparo.
|
|
166
|
+
*
|
|
167
|
+
* Nunca lança: campo ausente, opção inexistente ou falha de rede viram `false`.
|
|
168
|
+
* O tipo é informação de organização; derrubar por causa dele um comando que
|
|
169
|
+
* moveu a Etapa seria trocar o essencial pelo acessório.
|
|
170
|
+
*
|
|
171
|
+
* @returns {Promise<boolean>} true se escreveu o campo agora
|
|
172
|
+
*/
|
|
173
|
+
export async function ensureWorkItemType(token, project, typeField, itemId, itemType) {
|
|
174
|
+
if (!typeField?.id || !itemType) return false;
|
|
175
|
+
if (!WORK_ITEM_TYPES.includes(itemType)) return false;
|
|
176
|
+
const optionId = typeField.options?.[itemType];
|
|
177
|
+
if (!optionId) return false;
|
|
178
|
+
try {
|
|
179
|
+
const current = await getItemSingleSelectValue(token, itemId, typeField.id);
|
|
180
|
+
if (current) return false; // já tem tipo — nunca sobrescreve
|
|
181
|
+
await setItemSingleSelect(token, project.id, itemId, typeField.id, optionId);
|
|
182
|
+
return true;
|
|
183
|
+
} catch (err) {
|
|
184
|
+
// Avisa em vez de calar: foi o silêncio que deixou 120 itens sem tipo por
|
|
185
|
+
// seis features seguidas sem ninguém perceber.
|
|
186
|
+
console.warn(`Work Item Type "${itemType}" não pôde ser escrito: ${err.message}`);
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
119
191
|
/**
|
|
120
192
|
* Avança um item do board para `targetStage` (Etapa) e define o Status para
|
|
121
193
|
* `targetStatus`. Uma issue só AVANÇA: se já estiver em `targetStage` ou em uma
|
|
@@ -128,10 +200,18 @@ export function resolveStageName(input) {
|
|
|
128
200
|
* @param {string} nodeId node id da issue
|
|
129
201
|
* @param {string} targetStage nome da etapa de destino
|
|
130
202
|
* @param {string} targetStatus valor do Status (Todo/In Progress/Done)
|
|
203
|
+
* @param {object} [opts]
|
|
204
|
+
* @param {{id,options}|null} [opts.typeField] campo "Work Item Type" (ver resolveField)
|
|
205
|
+
* @param {string} [opts.itemType] tipo do item ('Story', 'Task', …) — escrito
|
|
206
|
+
* apenas se o campo estiver vazio (ver ensureWorkItemType)
|
|
131
207
|
* @returns {Promise<boolean>} true se avançou; false se já estava adiante
|
|
132
208
|
*/
|
|
133
|
-
export async function advanceToStage(
|
|
209
|
+
export async function advanceToStage(
|
|
210
|
+
token, project, etapaField, statusField, nodeId, targetStage, targetStatus, opts = {}
|
|
211
|
+
) {
|
|
134
212
|
const itemId = await addProjectItem(token, project.id, nodeId);
|
|
213
|
+
// Antes do guard de avanço: item que não avança também precisa do reparo.
|
|
214
|
+
await ensureWorkItemType(token, project, opts.typeField, itemId, opts.itemType);
|
|
135
215
|
|
|
136
216
|
if (etapaField?.id && targetStage) {
|
|
137
217
|
// Nunca retroceder — a decisão vive em shouldAdvanceStage (pura, testada).
|
|
@@ -167,9 +247,12 @@ export async function advanceToStage(token, project, etapaField, statusField, no
|
|
|
167
247
|
* @param {string} nodeId node id da issue
|
|
168
248
|
* @param {string} targetStage etapa de destino (precisa existir em STAGE_ORDER)
|
|
169
249
|
* @param {string} [targetStatus] valor do Status; omitido = não mexe no Status
|
|
250
|
+
* @param {object} [opts] mesmo `{ typeField, itemType }` de advanceToStage
|
|
170
251
|
* @returns {Promise<{ from: string|null }>} etapa em que o item estava
|
|
171
252
|
*/
|
|
172
|
-
export async function setItemStage(
|
|
253
|
+
export async function setItemStage(
|
|
254
|
+
token, project, etapaField, statusField, nodeId, targetStage, targetStatus, opts = {}
|
|
255
|
+
) {
|
|
173
256
|
if (!etapaField?.id) throw new Error('Campo "Etapa" não encontrado no Project — nada a reparar.');
|
|
174
257
|
if (STAGE_ORDER.indexOf(targetStage) === -1) {
|
|
175
258
|
throw new Error(`Etapa "${targetStage}" não faz parte do fluxo (${STAGE_ORDER.join(' → ')}).`);
|
|
@@ -182,6 +265,7 @@ export async function setItemStage(token, project, etapaField, statusField, node
|
|
|
182
265
|
);
|
|
183
266
|
}
|
|
184
267
|
const itemId = await addProjectItem(token, project.id, nodeId);
|
|
268
|
+
await ensureWorkItemType(token, project, opts.typeField, itemId, opts.itemType);
|
|
185
269
|
const from = await getItemSingleSelectValue(token, itemId, etapaField.id).catch(() => null);
|
|
186
270
|
await setItemSingleSelect(token, project.id, itemId, etapaField.id, optionId);
|
|
187
271
|
if (statusField?.id && targetStatus) {
|
package/src/lib/bug-doc.mjs
CHANGED
|
@@ -49,3 +49,74 @@ export function findMissingSections(content, sections) {
|
|
|
49
49
|
const text = String(content || '');
|
|
50
50
|
return (sections || []).filter(section => !text.includes(`# ${section}`));
|
|
51
51
|
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Títulos presentes no documento (função PURA).
|
|
55
|
+
*/
|
|
56
|
+
export function listHeadings(content) {
|
|
57
|
+
return [...String(content || '').matchAll(/^#{1,6}[ \t]+(.+?)[ \t]*$/gm)].map(m => m[1].trim());
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Comparação tolerante: sem acento, sem pontuação, caixa única.
|
|
61
|
+
function normalizeHeading(value) {
|
|
62
|
+
return String(value ?? '')
|
|
63
|
+
.normalize('NFD')
|
|
64
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
65
|
+
.replace(/[^\p{Letter}\p{Number}]+/gu, ' ')
|
|
66
|
+
.trim()
|
|
67
|
+
.toLowerCase();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function bigrams(value) {
|
|
71
|
+
const s = normalizeHeading(value).replace(/ /g, '');
|
|
72
|
+
const out = new Set();
|
|
73
|
+
for (let i = 0; i < s.length - 1; i++) out.add(s.slice(i, i + 2));
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Semelhança entre dois títulos, 0..1 (função PURA — coeficiente de Dice).
|
|
79
|
+
*/
|
|
80
|
+
export function headingSimilarity(a, b) {
|
|
81
|
+
const A = bigrams(a);
|
|
82
|
+
const B = bigrams(b);
|
|
83
|
+
if (A.size === 0 || B.size === 0) return normalizeHeading(a) === normalizeHeading(b) ? 1 : 0;
|
|
84
|
+
let comuns = 0;
|
|
85
|
+
for (const g of A) if (B.has(g)) comuns += 1;
|
|
86
|
+
return (2 * comuns) / (A.size + B.size);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Abaixo disto são dois títulos diferentes, não um errado. "Rollout e
|
|
90
|
+
// Monitoramento" × "Rollback e Monitoramento" fica bem acima; "Riscos" ×
|
|
91
|
+
// "Rollback e Monitoramento", bem abaixo.
|
|
92
|
+
const SIMILARIDADE_MINIMA = 0.6;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* O que falta E o que existe no lugar (função PURA).
|
|
96
|
+
*
|
|
97
|
+
* `findMissingSections` só sabe dizer o que não achou, e foi isso que fez uma
|
|
98
|
+
* Feature ser reprovada por ter escrito "# Rollout e Monitoramento" no lugar de
|
|
99
|
+
* "# Rollback e Monitoramento" — com todo o conteúdo certo embaixo. A mensagem
|
|
100
|
+
* dizia "seção ausente" e o humano tinha que caçar a diferença de uma palavra.
|
|
101
|
+
*
|
|
102
|
+
* Só sugere um título que NÃO satisfaz nenhuma seção obrigatória: senão o
|
|
103
|
+
* "Riscos" legítimo do documento vira sugestão para o "Rollback" que falta.
|
|
104
|
+
*
|
|
105
|
+
* @returns {Array<{section: string, found: string|null}>}
|
|
106
|
+
*/
|
|
107
|
+
export function describeMissingSections(content, sections) {
|
|
108
|
+
const faltando = findMissingSections(content, sections);
|
|
109
|
+
if (faltando.length === 0) return [];
|
|
110
|
+
const presentes = (sections || []).filter(s => !faltando.includes(s)).map(normalizeHeading);
|
|
111
|
+
const candidatos = listHeadings(content)
|
|
112
|
+
.filter(h => !presentes.includes(normalizeHeading(h)));
|
|
113
|
+
return faltando.map(section => {
|
|
114
|
+
let melhor = null;
|
|
115
|
+
let score = SIMILARIDADE_MINIMA;
|
|
116
|
+
for (const h of candidatos) {
|
|
117
|
+
const s = headingSimilarity(h, section);
|
|
118
|
+
if (s >= score) { score = s; melhor = h; }
|
|
119
|
+
}
|
|
120
|
+
return { section, found: melhor };
|
|
121
|
+
});
|
|
122
|
+
}
|
package/src/lib/claude.mjs
CHANGED
|
@@ -193,6 +193,21 @@ export function isTransientProviderError(err) {
|
|
|
193
193
|
.test(err.message || '');
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
/**
|
|
197
|
+
* O teto de turnos merece UMA repetição? (função PURA)
|
|
198
|
+
*
|
|
199
|
+
* Só quando o erro se classificou como exploração (ver MaxTurnsError): aí a
|
|
200
|
+
* falha é variância entre execuções, e a repetição costuma custar uma fração do
|
|
201
|
+
* run que falhou. Loop degenerado continua sem retry — repetir o determinístico
|
|
202
|
+
* foi o que custou 55 minutos de Action.
|
|
203
|
+
*
|
|
204
|
+
* UMA, e não `attempts`: o ganho observado está na segunda tentativa; da
|
|
205
|
+
* terceira em diante o padrão vira "gastar caro para confirmar o óbvio".
|
|
206
|
+
*/
|
|
207
|
+
export function shouldRetryMaxTurns(err) {
|
|
208
|
+
return Boolean(err?.maxTurns && err.exploration);
|
|
209
|
+
}
|
|
210
|
+
|
|
196
211
|
export async function withRetry(label, fn, { attempts = RETRY_ATTEMPTS, baseMs = RETRY_BASE_MS } = {}) {
|
|
197
212
|
let lastErr;
|
|
198
213
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
@@ -200,11 +215,12 @@ export async function withRetry(label, fn, { attempts = RETRY_ATTEMPTS, baseMs =
|
|
|
200
215
|
return await fn();
|
|
201
216
|
} catch (err) {
|
|
202
217
|
lastErr = err;
|
|
203
|
-
|
|
218
|
+
const repeteTeto = attempt === 1 && shouldRetryMaxTurns(err);
|
|
219
|
+
if (attempt === attempts || (!isTransientProviderError(err) && !repeteTeto)) break;
|
|
204
220
|
const delayMs = baseMs * 2 ** (attempt - 1); // 2s, 4s, 8s…
|
|
205
221
|
console.warn(
|
|
206
|
-
`${label}:
|
|
207
|
-
`repetindo em ${delayMs / 1000}s.`
|
|
222
|
+
`${label}: ${repeteTeto ? 'teto de turnos gasto explorando' : 'falha transitória'} na ` +
|
|
223
|
+
`tentativa ${attempt}/${attempts} (${err.message}) — repetindo em ${delayMs / 1000}s.`
|
|
208
224
|
);
|
|
209
225
|
await sleep(delayMs);
|
|
210
226
|
}
|
|
@@ -530,9 +546,9 @@ async function generateViaEngine(
|
|
|
530
546
|
|
|
531
547
|
const text = stripReasoning(result.outputText || '');
|
|
532
548
|
if (!text) {
|
|
533
|
-
// Teto de turnos tem causa e remédio próprios
|
|
534
|
-
//
|
|
535
|
-
//
|
|
549
|
+
// Teto de turnos tem causa e remédio próprios. Só é repetível quando as
|
|
550
|
+
// tool calls foram de EXPLORAÇÃO (variância); loop degenerado não é.
|
|
551
|
+
// Quem classifica é o próprio erro. Ver MaxTurnsError.
|
|
536
552
|
if (result.resultSubtype === 'error_max_turns') {
|
|
537
553
|
throw new MaxTurnsError({
|
|
538
554
|
provider: ai.provider,
|
|
@@ -540,6 +556,7 @@ async function generateViaEngine(
|
|
|
540
556
|
turns: result.numTurns,
|
|
541
557
|
action: action || null,
|
|
542
558
|
toolCalls: result.toolCalls || [],
|
|
559
|
+
toolSignatures: result.toolSignatures || [],
|
|
543
560
|
});
|
|
544
561
|
}
|
|
545
562
|
const err = new Error(
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// ## Story 1 — <título curto>
|
|
21
21
|
//
|
|
22
22
|
// **User story:** Como <perfil>, quero <objetivo>, para <benefício>
|
|
23
|
-
// **Depende de:** Story 1,
|
|
23
|
+
// **Depende de:** Story 1, #412 (ou "—" para nenhuma)
|
|
24
24
|
//
|
|
25
25
|
// <corpo livre, multi-linha>
|
|
26
26
|
//
|
|
@@ -162,6 +162,16 @@ function normalizeDependsOn(dependsOn, index) {
|
|
|
162
162
|
.sort((a, b) => a - b);
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
// Dependências para FORA da Feature: números de issue que já existem.
|
|
166
|
+
//
|
|
167
|
+
// A regra "só aponta para trás" NÃO se aplica a elas — ela existe para que a
|
|
168
|
+
// ordem de criação do apply seja topologicamente válida, e uma issue que já
|
|
169
|
+
// existe não é criada por este apply. O que vale aqui é ser inteiro positivo.
|
|
170
|
+
function normalizeDependsOnIssues(list) {
|
|
171
|
+
if (!Array.isArray(list)) return [];
|
|
172
|
+
return [...new Set(list.filter(n => Number.isInteger(n) && n > 0))].sort((a, b) => a - b);
|
|
173
|
+
}
|
|
174
|
+
|
|
165
175
|
/**
|
|
166
176
|
* Renderiza o decomposition.md (função PURA — testável).
|
|
167
177
|
*
|
|
@@ -223,12 +233,16 @@ export function renderDecompositionDoc({
|
|
|
223
233
|
(stories || []).forEach((story, i) => {
|
|
224
234
|
blocks.push(`## Story ${i + 1} — ${flatten(story?.title) || '(sem título)'}`);
|
|
225
235
|
const deps = normalizeDependsOn(story?.dependsOn, i);
|
|
236
|
+
// Irmãs primeiro (por índice), depois as externas (por número): a saída é
|
|
237
|
+
// canônica, então duas escritas do mesmo conteúdo dão o mesmo arquivo.
|
|
238
|
+
const externas = normalizeDependsOnIssues(story?.dependsOnIssues);
|
|
239
|
+
const refs = [...deps.map(d => `Story ${d + 1}`), ...externas.map(n => `#${n}`)];
|
|
226
240
|
// As DUAS linhas de campo saem SEMPRE, seguidas de linha em branco: é essa
|
|
227
241
|
// linha em branco que impede um corpo começando com "**Depende de:** …" de
|
|
228
242
|
// ser confundido com o campo.
|
|
229
243
|
const campos = [
|
|
230
244
|
`**User story:** ${flatten(story?.userStory) || '—'}`,
|
|
231
|
-
`**Depende de:** ${
|
|
245
|
+
`**Depende de:** ${refs.length ? refs.join(', ') : '—'}`,
|
|
232
246
|
];
|
|
233
247
|
const linhaIssue = issueLine(story);
|
|
234
248
|
if (linhaIssue) campos.push(linhaIssue);
|
|
@@ -276,28 +290,62 @@ function requireTitle(value, anchor) {
|
|
|
276
290
|
// "Story 1, Story 3" → [0, 2]. Linha ausente ou "—" → []. Referência a si mesma
|
|
277
291
|
// ou a uma story POSTERIOR é erro: num arquivo editado à mão, filtrar em
|
|
278
292
|
// silêncio (como se fazia com o JSON do modelo) esconderia o engano do humano.
|
|
293
|
+
// Só separadores e conjunções podem sobrar depois de extrair as referências.
|
|
294
|
+
// Sem esta checagem, "**Depende de:** #12 no sistema legado" viraria "depende da
|
|
295
|
+
// issue 12" e a prosa sumiria em silêncio — o erro que este parser existe para
|
|
296
|
+
// não cometer.
|
|
297
|
+
const DEPENDS_LEFTOVER_RE = /^[\s,;+&/–—-]*(?:\b(?:e|and)\b[\s,;+&/–—-]*)*$/i;
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Interpreta o valor de "**Depende de:**" (função PURA).
|
|
301
|
+
*
|
|
302
|
+
* Duas formas convivem na mesma linha, e cada uma diz uma coisa diferente:
|
|
303
|
+
* • `Story N` — irmã, no MESMO documento, sempre para trás (é o que mantém a
|
|
304
|
+
* ordem de criação do apply topologicamente válida);
|
|
305
|
+
* • `#N` — issue que JÁ existe, de qualquer Feature. Sem ela, tudo que cruza a
|
|
306
|
+
* fronteira da Feature vivia na prosa, e nenhuma automação enxergava.
|
|
307
|
+
*
|
|
308
|
+
* @returns {{ siblings: number[], issues: number[] }} siblings 0-based
|
|
309
|
+
*/
|
|
279
310
|
function parseDependsValue(raw, index, anchor) {
|
|
280
|
-
if (raw === null || raw === undefined) return [];
|
|
311
|
+
if (raw === null || raw === undefined) return { siblings: [], issues: [] };
|
|
281
312
|
const value = raw.trim();
|
|
282
|
-
if (!value || EMPTY_VALUE_RE.test(value)) return [];
|
|
283
|
-
|
|
284
|
-
|
|
313
|
+
if (!value || EMPTY_VALUE_RE.test(value)) return { siblings: [], issues: [] };
|
|
314
|
+
|
|
315
|
+
const storyMatches = [...value.matchAll(/story[ \t]*(\d+)/gi)];
|
|
316
|
+
const issueMatches = [...value.matchAll(/#(\d+)/g)];
|
|
317
|
+
if (storyMatches.length === 0 && issueMatches.length === 0) {
|
|
318
|
+
throw invalid(
|
|
319
|
+
`não entendi "**Depende de:** ${value}" em ${anchor} — use "Story 1", "#412" ou "—"`
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
const resto = value
|
|
323
|
+
.replace(/story[ \t]*\d+/gi, '')
|
|
324
|
+
.replace(/#\d+/g, '');
|
|
325
|
+
if (!DEPENDS_LEFTOVER_RE.test(resto)) {
|
|
285
326
|
throw invalid(
|
|
286
|
-
`não entendi "**Depende de:** ${value}" em ${anchor} —
|
|
327
|
+
`não entendi "**Depende de:** ${value}" em ${anchor} — sobrou "${resto.trim()}". ` +
|
|
328
|
+
'Use só referências ("Story 1, #412") ou "—"'
|
|
287
329
|
);
|
|
288
330
|
}
|
|
289
|
-
|
|
290
|
-
|
|
331
|
+
|
|
332
|
+
const siblings = [];
|
|
333
|
+
for (const m of storyMatches) {
|
|
291
334
|
const n = parseInt(m[1], 10);
|
|
292
335
|
if (n < 1 || n - 1 >= index) {
|
|
293
336
|
throw invalid(
|
|
294
337
|
`${anchor} depende de "Story ${n}", que não é uma Story anterior a ela — ` +
|
|
295
|
-
'dependências só apontam para trás'
|
|
338
|
+
'dependências entre irmãs só apontam para trás (para outra Feature, use "#<issue>")'
|
|
296
339
|
);
|
|
297
340
|
}
|
|
298
|
-
if (!
|
|
341
|
+
if (!siblings.includes(n - 1)) siblings.push(n - 1);
|
|
342
|
+
}
|
|
343
|
+
const issues = [];
|
|
344
|
+
for (const m of issueMatches) {
|
|
345
|
+
const n = parseInt(m[1], 10);
|
|
346
|
+
if (n > 0 && !issues.includes(n)) issues.push(n);
|
|
299
347
|
}
|
|
300
|
-
return
|
|
348
|
+
return { siblings: siblings.sort((a, b) => a - b), issues: issues.sort((a, b) => a - b) };
|
|
301
349
|
}
|
|
302
350
|
|
|
303
351
|
// Tasks não têm campos de gramática além do título — só a linha `**Issue:** #N`
|
|
@@ -352,7 +400,8 @@ function splitStorySection(lines, anchor) {
|
|
|
352
400
|
* Interpreta o decomposition.md (função PURA — testável).
|
|
353
401
|
*
|
|
354
402
|
* Devolve exatamente a shape que o loop de criação de issues consome
|
|
355
|
-
* (`stories[].{title,userStory,body,dependsOn,tasks[]}`, com
|
|
403
|
+
* (`stories[].{title,userStory,body,dependsOn,dependsOnIssues,tasks[]}`, com
|
|
404
|
+
* dependsOn 0-based entre irmãs e dependsOnIssues em números de issue),
|
|
356
405
|
* mais os metadados do marcador e a âncora estável de cada item ("Story 3",
|
|
357
406
|
* "Task 3.2") — é a âncora que a crítica cita no comentário da issue.
|
|
358
407
|
*
|
|
@@ -473,7 +522,10 @@ export function parseDecompositionDoc(markdown) {
|
|
|
473
522
|
userStory,
|
|
474
523
|
body,
|
|
475
524
|
issue,
|
|
476
|
-
|
|
525
|
+
...(() => {
|
|
526
|
+
const { siblings, issues } = parseDependsValue(depends, doc.stories.length, anchor);
|
|
527
|
+
return { dependsOn: siblings, dependsOnIssues: issues };
|
|
528
|
+
})(),
|
|
477
529
|
tasks: [],
|
|
478
530
|
});
|
|
479
531
|
continue;
|
package/src/lib/dependencies.mjs
CHANGED
|
@@ -44,8 +44,14 @@ export function parseDependencies(body) {
|
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
46
|
* Ordena Stories topologicamente pelas dependências (Kahn). Estável: entre as
|
|
47
|
-
* Stories liberadas ao mesmo tempo, vence a de menor number.
|
|
48
|
-
*
|
|
47
|
+
* Stories liberadas ao mesmo tempo, vence a de menor number.
|
|
48
|
+
*
|
|
49
|
+
* Dependência para FORA do conjunto (uma Story de outra Feature) não participa
|
|
50
|
+
* da ordenação — sem ela no conjunto não há como saber onde entra —, mas deixou
|
|
51
|
+
* de ser DESCARTADA: volta em `external`, para o chamador mostrar quem está
|
|
52
|
+
* bloqueado por fora. Antes ela era lida, filtrada aqui e ignorada de novo no
|
|
53
|
+
* aviso de fora-de-ordem: o dado entrava e sumia sem nenhuma mensagem, que é
|
|
54
|
+
* pior do que não aceitá-lo.
|
|
49
55
|
*
|
|
50
56
|
* Contrato: NUNCA lança. Retorna sempre `{ order, cycle }`:
|
|
51
57
|
* • sem ciclo → order = todos os numbers em ordem de execução, cycle = [];
|
|
@@ -54,15 +60,19 @@ export function parseDependencies(body) {
|
|
|
54
60
|
* O chamador decide se trata cycle.length > 0 como erro.
|
|
55
61
|
*
|
|
56
62
|
* @param {Array<{ number: number, dependsOn: number[] }>} stories
|
|
57
|
-
* @returns {{ order: number[], cycle: number[] }}
|
|
63
|
+
* @returns {{ order: number[], cycle: number[], external: Map<number, number[]> }}
|
|
64
|
+
* external: number da Story → dependências fora do conjunto
|
|
58
65
|
*/
|
|
59
66
|
export function orderStories(stories) {
|
|
60
67
|
const known = new Set(stories.map(s => s.number));
|
|
61
68
|
// indegree = quantas dependências INTERNAS ainda não resolvidas.
|
|
62
69
|
const indegree = new Map();
|
|
63
70
|
const dependents = new Map(); // number → numbers que dependem dele
|
|
71
|
+
const external = new Map();
|
|
64
72
|
for (const s of stories) {
|
|
65
73
|
const deps = (s.dependsOn || []).filter(d => known.has(d) && d !== s.number);
|
|
74
|
+
const fora = (s.dependsOn || []).filter(d => !known.has(d) && d !== s.number);
|
|
75
|
+
if (fora.length > 0) external.set(s.number, fora.sort((a, b) => a - b));
|
|
66
76
|
indegree.set(s.number, deps.length);
|
|
67
77
|
for (const d of deps) {
|
|
68
78
|
if (!dependents.has(d)) dependents.set(d, []);
|
|
@@ -88,7 +98,7 @@ export function orderStories(stories) {
|
|
|
88
98
|
.filter(([, deg]) => deg > 0)
|
|
89
99
|
.map(([n]) => n)
|
|
90
100
|
.sort((a, b) => a - b);
|
|
91
|
-
return { order, cycle };
|
|
101
|
+
return { order, cycle, external };
|
|
92
102
|
}
|
|
93
103
|
|
|
94
104
|
/**
|
|
@@ -22,8 +22,8 @@ import {
|
|
|
22
22
|
* story?: {nodeId:string,number:number}|null,
|
|
23
23
|
* bug?: {nodeId:string,number:number}|null,
|
|
24
24
|
* tasks?: Array<{nodeId?:string,number:number}> }} refs
|
|
25
|
-
* @returns {Array<{nodeId:string, label:string,
|
|
26
|
-
* statusFallback:boolean}>}
|
|
25
|
+
* @returns {Array<{nodeId:string, label:string, type:string, stage:string,
|
|
26
|
+
* status:string, statusFallback:boolean}>}
|
|
27
27
|
* statusFallback: se a Etapa já estiver adiante (advanceToStage devolve
|
|
28
28
|
* false), ainda assim alinhar o Status — usado nos movimentos de início
|
|
29
29
|
* (ex.: Feature já em Desenvolvimento volta a mostrar In Progress).
|
|
@@ -37,37 +37,37 @@ export function planBoardMoves(phase, {
|
|
|
37
37
|
const moves = [];
|
|
38
38
|
if (phase === 'start') {
|
|
39
39
|
if (feature?.nodeId) {
|
|
40
|
-
moves.push({ nodeId: feature.nodeId, label: `Feature #${feature.number}`,
|
|
40
|
+
moves.push({ nodeId: feature.nodeId, label: `Feature #${feature.number}`, type: 'Feature',
|
|
41
41
|
stage: STAGE_DEVELOPMENT, status: PROGRESS_IN_PROGRESS, statusFallback: true });
|
|
42
42
|
}
|
|
43
43
|
if (story?.nodeId) {
|
|
44
|
-
moves.push({ nodeId: story.nodeId, label: `Story #${story.number}`,
|
|
44
|
+
moves.push({ nodeId: story.nodeId, label: `Story #${story.number}`, type: 'Story',
|
|
45
45
|
stage: STAGE_DEVELOPMENT, status: PROGRESS_IN_PROGRESS, statusFallback: true });
|
|
46
46
|
}
|
|
47
47
|
// Bug é folha e não tem Feature-pai a arrastar: um defeito em correção não
|
|
48
48
|
// deve puxar a Feature inteira de volta para Desenvolvimento.
|
|
49
49
|
if (bug?.nodeId) {
|
|
50
|
-
moves.push({ nodeId: bug.nodeId, label: `Bug #${bug.number}`,
|
|
50
|
+
moves.push({ nodeId: bug.nodeId, label: `Bug #${bug.number}`, type: 'Bug',
|
|
51
51
|
stage: STAGE_DEVELOPMENT, status: PROGRESS_IN_PROGRESS, statusFallback: true });
|
|
52
52
|
}
|
|
53
53
|
for (const t of tasks) {
|
|
54
54
|
if (!t?.nodeId) continue;
|
|
55
|
-
moves.push({ nodeId: t.nodeId, label: `Task #${t.number}`,
|
|
55
|
+
moves.push({ nodeId: t.nodeId, label: `Task #${t.number}`, type: 'Task',
|
|
56
56
|
stage: STAGE_DEVELOPMENT, status: tasksStartStatus,
|
|
57
57
|
statusFallback: tasksStartStatus === PROGRESS_IN_PROGRESS });
|
|
58
58
|
}
|
|
59
59
|
} else if (phase === 'success') {
|
|
60
60
|
for (const t of tasks) {
|
|
61
61
|
if (!t?.nodeId) continue;
|
|
62
|
-
moves.push({ nodeId: t.nodeId, label: `Task #${t.number}`,
|
|
62
|
+
moves.push({ nodeId: t.nodeId, label: `Task #${t.number}`, type: 'Task',
|
|
63
63
|
stage: STAGE_DONE, status: PROGRESS_DONE, statusFallback: false });
|
|
64
64
|
}
|
|
65
65
|
if (story?.nodeId) {
|
|
66
|
-
moves.push({ nodeId: story.nodeId, label: `Story #${story.number}`,
|
|
66
|
+
moves.push({ nodeId: story.nodeId, label: `Story #${story.number}`, type: 'Story',
|
|
67
67
|
stage: STAGE_CODE_REVIEW, status: PROGRESS_TODO, statusFallback: false });
|
|
68
68
|
}
|
|
69
69
|
if (bug?.nodeId) {
|
|
70
|
-
moves.push({ nodeId: bug.nodeId, label: `Bug #${bug.number}`,
|
|
70
|
+
moves.push({ nodeId: bug.nodeId, label: `Bug #${bug.number}`, type: 'Bug',
|
|
71
71
|
stage: STAGE_CODE_REVIEW, status: PROGRESS_TODO, statusFallback: false });
|
|
72
72
|
}
|
|
73
73
|
// Feature: só o modo Feature passa `feature` aqui, e só depois de TODAS as
|
|
@@ -77,7 +77,7 @@ export function planBoardMoves(phase, {
|
|
|
77
77
|
// num branch único e não abre PR), a Feature ficava presa em
|
|
78
78
|
// Desenvolvimento com todas as Stories já em Code Review.
|
|
79
79
|
if (feature?.nodeId) {
|
|
80
|
-
moves.push({ nodeId: feature.nodeId, label: `Feature #${feature.number}`,
|
|
80
|
+
moves.push({ nodeId: feature.nodeId, label: `Feature #${feature.number}`, type: 'Feature',
|
|
81
81
|
stage: STAGE_CODE_REVIEW, status: PROGRESS_TODO, statusFallback: false });
|
|
82
82
|
}
|
|
83
83
|
}
|
|
@@ -113,6 +113,9 @@ export async function applyBoardMoves({
|
|
|
113
113
|
const projectToken = process.env.PROJECT_TOKEN || token;
|
|
114
114
|
const etapaField = await resolveField(projectToken, project, 'Etapa').catch(() => null);
|
|
115
115
|
const statusField = await resolveField(projectToken, project, 'Status').catch(() => null);
|
|
116
|
+
// Repara o Work Item Type vazio de passagem — o tipo de cada movimento é
|
|
117
|
+
// conhecido por construção (planBoardMoves). Ver ensureWorkItemType.
|
|
118
|
+
const typeField = await resolveField(projectToken, project, 'Work Item Type').catch(() => null);
|
|
116
119
|
if (!etapaField?.id) {
|
|
117
120
|
log.warn('board: campo Etapa não resolvido — Etapas não atualizadas.');
|
|
118
121
|
return;
|
|
@@ -120,7 +123,8 @@ export async function applyBoardMoves({
|
|
|
120
123
|
for (const m of moves) {
|
|
121
124
|
try {
|
|
122
125
|
const advanced = await advanceToStage(
|
|
123
|
-
projectToken, project, etapaField, statusField, m.nodeId, m.stage, m.status
|
|
126
|
+
projectToken, project, etapaField, statusField, m.nodeId, m.stage, m.status,
|
|
127
|
+
{ typeField, itemType: m.type });
|
|
124
128
|
if (advanced) {
|
|
125
129
|
log.info(`board: ${m.label} → ${m.stage} (${m.status})`);
|
|
126
130
|
} else if (m.statusFallback && statusField?.id) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spec-wave",
|
|
3
3
|
"displayName": "Spec Wave",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.23.0",
|
|
5
5
|
"description": "Fluxo spec-driven no GitHub (RFC-001): Projects v2, labels de gatilho, spec/plan gerados por Action, decomposição em duas etapas e implementação orientada a Stories/Tasks.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Astratech",
|
|
@@ -64,7 +64,7 @@ spec-wave:decompose-apply
|
|
|
64
64
|
```
|
|
65
65
|
Aplicar essa label **é** a aprovação humana — não há nova crítica.
|
|
66
66
|
|
|
67
|
-
5. Após a aplicação, as issues filhas aparecem como comentário na issue pai. Pai e filhas entram no board em **✅ Ready** (Status Todo; a Etapa nunca retrocede — itens já adiante não são tocados). As Stories trazem `Depende de: #N` — use a skill **order** para ver a ordem de execução.
|
|
67
|
+
5. Após a aplicação, as issues filhas aparecem como comentário na issue pai. Pai e filhas entram no board em **✅ Ready** (Status Todo; a Etapa nunca retrocede — itens já adiante não são tocados). As Stories trazem `Depende de: #N` — use a skill **order** para ver a ordem de execução. Se a issue pai tiver **milestone**, as filhas nascem nele (a entrega da Story pertence à release da Feature); sem milestone no pai, nascem sem.
|
|
68
68
|
|
|
69
69
|
6. A issue recebe `spec-wave:decomposed` (guard de idempotência): rodar de novo **não** duplica as issues.
|
|
70
70
|
|
|
@@ -93,7 +93,7 @@ Corpo técnico.
|
|
|
93
93
|
**Ao editar à mão:**
|
|
94
94
|
|
|
95
95
|
- a **posição** manda, não o número escrito — inserir uma Story no meio sem renumerar funciona
|
|
96
|
-
- `**Depende de:**`
|
|
96
|
+
- `**Depende de:**` aceita **irmãs** (`Story 1, Story 3`, 1-based, só para trás — apontar para si mesma ou para frente é **erro**, não filtro silencioso) e **issues de outras Features** (`#412`, que precisam JÁ existir); as duas formas convivem na mesma linha (`Story 1, #412`), e `—` significa nenhuma
|
|
97
97
|
- o corpo aceita markdown livre (`## Backend`, cercas de código) — só `## Story N` e `### Task N.M` são estrutura
|
|
98
98
|
- **para gerar outro rascunho do zero:** apague o arquivo e reaplique `spec-wave:decompose`
|
|
99
99
|
|
|
@@ -114,4 +114,4 @@ Depois **apague/feche as sub-issues antigas** (senão a detecção por sub-issue
|
|
|
114
114
|
|
|
115
115
|
## Dependências entre Stories
|
|
116
116
|
|
|
117
|
-
O `decompose` grava `Depende de: #N, #M` no corpo das Stories e cria a relação nativa *blocked by
|
|
117
|
+
O `decompose` grava `Depende de: #N, #M` no corpo das Stories e cria a relação nativa *blocked by* — para irmãs e para as issues de outras Features referenciadas com `#N` no rascunho (a issue precisa existir: o apply reprova o rascunho ANTES de criar qualquer coisa se não conseguir lê-la). Isso alimenta as skills **order** e **implement**. **Não apague essa linha** ao editar o corpo de uma Story; para mudar dependências, edite a linha (e/ou a relação *blocked by*).
|
|
@@ -11,12 +11,15 @@ allowed-tools:
|
|
|
11
11
|
Comando **local**:
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
|
-
npx @spec-wave/cli@latest order <feature>
|
|
14
|
+
npx @spec-wave/cli@latest order <feature> # uma Feature
|
|
15
|
+
npx @spec-wave/cli@latest order # o mapa de todas as Features com trabalho
|
|
15
16
|
```
|
|
16
17
|
|
|
17
18
|
| Arg | Descrição |
|
|
18
19
|
|-----|-----------|
|
|
19
|
-
| `<feature>` | Número da issue da **Feature**, ex.: `12` ou `#12`. Posicional,
|
|
20
|
+
| `<feature>` | Número da issue da **Feature**, ex.: `12` ou `#12`. Posicional, **opcional**. |
|
|
21
|
+
|
|
22
|
+
**Sem argumento**, o conjunto vem do **board** (não dos arquivos): todas as Features abertas fora de 🎉 Done, com as Stories de todas num **grafo só** e a Feature de cada uma ao lado. É o modo para responder "por onde os devs pegam agora" quando o trabalho está espalhado por várias Features — nesse escopo, dependência entre Features deixa de ser "externa" e entra na ordenação.
|
|
20
23
|
|
|
21
24
|
**Contexto:** leia `.spec-wave.json` (Read). Ausente → skill **setup**.
|
|
22
25
|
|
|
@@ -25,11 +28,12 @@ npx @spec-wave/cli@latest order <feature>
|
|
|
25
28
|
- As Stories da Feature em **ordem topológica** pelas dependências — a linha `Depende de: #N` no corpo **mesclada** com a relação nativa *blocked by* do GitHub
|
|
26
29
|
- A **Etapa atual** de cada Story no board
|
|
27
30
|
- Avisos de **ciclo de dependência** — essas Stories ficam **fora da ordem**; corrija as linhas `Depende de:`
|
|
28
|
-
- Avisos de **dependência fora de ordem** — Story já em 🚧 Desenvolvimento ou além dependendo de outra que não está em 🎉 Done
|
|
31
|
+
- Avisos de **dependência fora de ordem** — Story já em 🚧 Desenvolvimento ou além dependendo de outra que não está em 🎉 Done (no modo de uma Feature, também quando a bloqueadora é de **outra** Feature e continua aberta)
|
|
32
|
+
- **Bloqueadas por fora desta Feature** (modo de uma Feature) — dependências `#N` que não entram na ordenação porque a Story bloqueadora não está no conjunto, com o estado de cada uma. No modo sem argumento essa seção lista só o que ficou fora do board (Story concluída, Feature em Done, outro board)
|
|
29
33
|
|
|
30
34
|
## Passos
|
|
31
35
|
|
|
32
|
-
1. Rode o comando para a Feature.
|
|
36
|
+
1. Rode o comando para a Feature — ou **sem argumento** quando a pergunta for sobre a onda inteira, não sobre uma Feature.
|
|
33
37
|
2. Apresente a ordem ao usuário, marcando o que já está concluído e o que está pendente.
|
|
34
38
|
3. **Se houver ciclo**, isso é bloqueante para a skill **implement** no modo Feature (o comando aborta com exit 1). Ajude a quebrar o ciclo editando as linhas `Depende de:` nos corpos das Stories.
|
|
35
39
|
4. **Se houver dependência fora de ordem**, aponte o risco ao usuário antes de seguir.
|
|
@@ -24,7 +24,7 @@ Verifica se `spec.md` e `plan.md` contêm todas as seções obrigatórias e se n
|
|
|
24
24
|
|
|
25
25
|
3. Informe: "Validação iniciada. O workflow verifica se `spec.md` e `plan.md` contêm todas as seções obrigatórias."
|
|
26
26
|
|
|
27
|
-
4. **Se a validação falhar por conteúdo**, o workflow comenta os problemas na issue e
|
|
27
|
+
4. **Se a validação falhar por conteúdo**, o workflow comenta os problemas na issue e **não aplica nenhuma label de gatilho**. Oriente o usuário a corrigir e reaplicar `spec-wave:ready`. Quando o problema é só o título de uma seção, o comentário já diz qual título encontrou e qual esperava — renomear resolve. Só sugira `spec-wave:spec` se o documento precisar mesmo ser REGERADO: essa label **sobrescreve** o `spec.md`, inclusive o que foi revisado à mão.
|
|
28
28
|
|
|
29
29
|
5. **Se passar:** "Feature validada! Mova o card para **✅ Ready** e use a skill **decompose** para gerar o **rascunho** das Stories — nada é criado ainda."
|
|
30
30
|
|