@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.
Files changed (38) hide show
  1. package/bin/spec-wave.mjs +2 -2
  2. package/package.json +1 -1
  3. package/src/agent/anthropic-agent.mjs +3 -1
  4. package/src/agent/errors.mjs +59 -13
  5. package/src/agent/openrouter-agent.mjs +5 -1
  6. package/src/api/github-graphql.mjs +127 -7
  7. package/src/api/github-rest.mjs +9 -2
  8. package/src/commands/code-review.mjs +15 -4
  9. package/src/commands/decompose.mjs +172 -23
  10. package/src/commands/doctor.mjs +88 -8
  11. package/src/commands/move.mjs +58 -1
  12. package/src/commands/order.mjs +200 -7
  13. package/src/commands/qa.mjs +8 -2
  14. package/src/commands/repair-stage.mjs +6 -1
  15. package/src/commands/story.mjs +6 -1
  16. package/src/commands/task.mjs +6 -1
  17. package/src/commands/triage.mjs +4 -1
  18. package/src/commands/validate.mjs +39 -18
  19. package/src/config.mjs +47 -3
  20. package/src/lib/board.mjs +87 -3
  21. package/src/lib/bug-doc.mjs +71 -0
  22. package/src/lib/claude.mjs +23 -6
  23. package/src/lib/decomposition-doc.mjs +66 -14
  24. package/src/lib/dependencies.mjs +14 -4
  25. package/src/lib/implement-board.mjs +15 -11
  26. package/src/plugin/.claude-plugin/plugin.json +1 -1
  27. package/src/plugin/skills/decompose/SKILL.md +3 -3
  28. package/src/plugin/skills/order/SKILL.md +8 -4
  29. package/src/plugin/skills/ready/SKILL.md +1 -1
  30. package/src/templates/skill/SKILL.md +4 -4
  31. package/src/templates/workflows/code-review.yml +9 -2
  32. package/src/templates/workflows/critique.yml +19 -2
  33. package/src/templates/workflows/decompose.yml +19 -2
  34. package/src/templates/workflows/generate-bug.yml +19 -2
  35. package/src/templates/workflows/generate-plan.yml +19 -2
  36. package/src/templates/workflows/generate-spec.yml +19 -2
  37. package/src/templates/workflows/qa.yml +5 -1
  38. 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(token, project, etapaField, statusField, nodeId, targetStage, targetStatus) {
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(token, project, etapaField, statusField, nodeId, targetStage, targetStatus) {
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) {
@@ -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
+ }
@@ -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
- if (attempt === attempts || !isTransientProviderError(err)) break;
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}: falha transitória na tentativa ${attempt}/${attempts} (${err.message}) ` +
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, e NÃO é transitório: o
534
- // modelo gastou todos os turnos em tool calls sem nunca escrever o
535
- // documento, e repetir reproduz isso. Ver MaxTurnsError.
549
+ // Teto de turnos tem causa e remédio próprios. é 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, Story 2 (ou "—" para nenhuma)
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:** ${deps.length ? deps.map(d => `Story ${d + 1}`).join(', ') : '—'}`,
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
- const matches = [...value.matchAll(/story[ \t]*(\d+)/gi)];
284
- if (matches.length === 0) {
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} — use "Story 1, Story 2" ou "—"`
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
- const out = [];
290
- for (const m of matches) {
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 (!out.includes(n - 1)) out.push(n - 1);
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 out.sort((a, b) => a - b);
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 dependsOn 0-based),
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
- dependsOn: parseDependsValue(depends, doc.stories.length, anchor),
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;
@@ -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. Dependências que
48
- * apontam para fora do conjunto (ex.: issue externa) são ignoradas.
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, stage:string, status: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.20.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:**` usa referências **1-based** (`Story 1, Story 3`) ou `—`; apontar para si mesma ou para frente é **erro**, não filtro silencioso
96
+ - `**Depende de:**` aceita **irmãs** (`Story 1, Story 3`, 1-based, 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*. 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*).
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, obrigatório. |
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 adiciona automaticamente `spec-wave:spec`. Oriente o usuário a corrigir e tentar de novo.
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 é 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