@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/bin/spec-wave.mjs CHANGED
@@ -263,8 +263,8 @@ program
263
263
 
264
264
  program
265
265
  .command('order')
266
- .description('Ordena as Stories de uma Feature pelas dependências (topológica)')
267
- .argument('<feature>', 'Número da issue da Feature, ex.: 12 ou #12')
266
+ .description('Ordena as Stories pelas dependências (topológica). Sem argumento, o mapa de todas as Features com trabalho')
267
+ .argument('[feature]', 'Número da issue da Feature, ex.: 12 ou #12. Omitido: todas as Features abertas fora de 🎉 Done')
268
268
  .action(async (feature) => {
269
269
  const { order } = await import('../src/commands/order.mjs');
270
270
  await order({ feature }).catch(err => { console.error(err.message); process.exit(1); });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spec-wave/cli",
3
- "version": "0.20.0",
3
+ "version": "0.23.0",
4
4
  "description": "Setup spec-driven GitHub workflow with Projects v2, labels, issue templates, and AI-powered Actions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,7 +25,7 @@
25
25
  import path from 'node:path';
26
26
  import { withTrace, startObservation, updateSpan, endObservation } from './tracing.mjs';
27
27
  import { ENV_FILE_PATTERN } from './tools.mjs';
28
- import { summarizeToolCalls } from './errors.mjs';
28
+ import { summarizeToolCalls, toolSignature } from './errors.mjs';
29
29
  import { TruncatedOutputError } from './errors.mjs';
30
30
 
31
31
  const DEFAULT_TOOLS = ['Read', 'Glob', 'Grep'];
@@ -72,6 +72,7 @@ export async function runAnthropicAgent(prompt, options) {
72
72
  // Simétrico ao backend openrouter: sem isto, um run que estoura o teto de
73
73
  // turnos não deixa registro do que o modelo esteve fazendo.
74
74
  toolCalls: [],
75
+ toolSignatures: [],
75
76
  };
76
77
  const quiet = options.quiet !== false;
77
78
 
@@ -159,6 +160,7 @@ export async function runAnthropicAgent(prompt, options) {
159
160
  }
160
161
 
161
162
  runResult.toolCalls.push(input.tool_name);
163
+ runResult.toolSignatures.push(toolSignature(input.tool_name, input.tool_input));
162
164
  if (options.verbose) {
163
165
  console.log(`\n[Tool] ${input.tool_name}(${JSON.stringify(input.tool_input)})`);
164
166
  }
@@ -45,34 +45,80 @@ export class TruncatedOutputError extends Error {
45
45
  }
46
46
  }
47
47
 
48
+ /**
49
+ * Assinatura de uma tool call: nome + ALVO (função PURA).
50
+ *
51
+ * `Read×59` não distingue "leu 59 arquivos diferentes" de "leu o mesmo arquivo
52
+ * 59 vezes", e essas duas situações pedem remédios opostos. O alvo é o que
53
+ * separa as duas, e é o único dado do input que entra aqui — nada de conteúdo.
54
+ */
55
+ export function toolSignature(name, input) {
56
+ const alvo = input && typeof input === 'object'
57
+ ? input.file_path ?? input.path ?? input.pattern ?? input.query ?? input.command ?? null
58
+ : null;
59
+ return alvo ? `${name}:${String(alvo)}` : String(name);
60
+ }
61
+
62
+ /**
63
+ * O modelo estava EXPLORANDO (e não preso num loop)? (função PURA)
64
+ *
65
+ * Explorar = chamadas majoritariamente distintas: leu muitos arquivos, varreu
66
+ * muitos padrões e o orçamento acabou antes de escrever. Isso VARIA entre
67
+ * execuções — repetir a mesma requisição costuma passar. Loop degenerado é o
68
+ * contrário: a mesma chamada repetida, que repetir só encarece.
69
+ *
70
+ * Amostra pequena não sustenta nenhuma das duas conclusões, e o default é o
71
+ * conservador (não repetir).
72
+ */
73
+ export function looksLikeExploration(signatures = []) {
74
+ if (!Array.isArray(signatures) || signatures.length < 4) return false;
75
+ const distintas = new Set(signatures).size;
76
+ return distintas / signatures.length >= 0.5;
77
+ }
78
+
48
79
  // Teto de turnos esgotado: o modelo respondeu com tool calls em TODOS os turnos
49
- // e nunca produziu o documento. Como o truncamento acima, NÃO é transitório —
50
- // repetir a mesma requisição dá a mesma perambulação, só que mais cara.
80
+ // e nunca produziu o documento.
81
+ //
82
+ // NÃO é uma falha só, são duas, e a distinção é o que decide se repetir vale:
83
+ //
84
+ // • LOOP DEGENERADO (a mesma chamada de novo e de novo) é determinístico —
85
+ // repetir custou 55 minutos de Action num caso real: três tentativas de 25
86
+ // turnos (4min → 19min → 32min) para a mesma falha, que devia ter aparecido
87
+ // na primeira. Aqui não se repete: troca-se o modelo ou sobe-se o teto.
51
88
  //
52
- // Marcá-lo como transitório custou 55 minutos de Action num caso real: três
53
- // tentativas de 25 turnos (4min 19min 32min) para a mesma falha
54
- // determinística, que devia ter aparecido na primeira.
89
+ // EXPLORAÇÃO (chamadas distintas: Read×59 em 59 arquivos, Glob×20) é
90
+ // VARIÂNCIA, não limite estrutural o orçamento acabou antes de escrever.
91
+ // Tratar isso como determinístico mandava trocar de modelo por nada: em
92
+ // quatro ocorrências reais, três reaplicações da MESMA label no MESMO modelo
93
+ // passaram na segunda tentativa, uma delas depois de um run de 20min46s que
94
+ // uma repetição de ~2min teria evitado.
55
95
  export class MaxTurnsError extends Error {
56
- constructor({ provider, model, turns, action, toolCalls = [] }) {
57
- // O que o modelo ficou fazendo é a informação que decide o remédio: muitas
58
- // leituras distintas sugerem teto baixo; a mesma chamada repetida é loop
59
- // degenerado, e aí subir o teto só encarece.
96
+ constructor({ provider, model, turns, action, toolCalls = [], toolSignatures = [] }) {
60
97
  const resumo = toolCalls.length > 0
61
98
  ? ` Ferramentas mais chamadas: ${summarizeToolCalls(toolCalls)}.`
62
99
  : '';
100
+ const exploracao = looksLikeExploration(toolSignatures);
101
+ const remedio = exploracao
102
+ ? 'As chamadas são majoritariamente DISTINTAS — o modelo gastou o orçamento explorando, ' +
103
+ 'o que varia entre execuções. Uma repetição costuma bastar (a CLI já faz UMA, ' +
104
+ 'automaticamente); se insistir, suba `maxTurns` no prompt em vez de trocar o modelo.'
105
+ : 'As chamadas se REPETEM — é loop degenerado, e repetir reproduz a mesma perambulação ' +
106
+ 'mais cara. Troque o modelo (por `ai.models` ou pela label ' +
107
+ '`spec-wave:model:<apelido>`) ou suba o teto de turnos.';
63
108
  super(
64
109
  `O modelo esgotou o teto de ${turns} turnos sem produzir o documento ` +
65
110
  `(${provider} · ${model}${action ? ` · ação=${action}` : ''}): respondeu com chamadas de ` +
66
- `ferramenta em todos eles.${resumo} ` +
67
- 'Repetir não ajuda — o erro é determinístico. Ou o modelo não fecha o loop de ' +
68
- 'ferramentas nesta tarefa (troque-o, por `ai.models` ou pela label ' +
69
- '`spec-wave:model:<apelido>`), ou a exploração precisa de mais turnos.'
111
+ `ferramenta em todos eles.${resumo} ${remedio}`
70
112
  );
71
113
  this.name = 'MaxTurnsError';
72
114
  this.maxTurns = true;
115
+ // Quem decide o retry (lib/claude.mjs) lê esta flag — a classificação mora
116
+ // aqui, junto dos dados que a sustentam.
117
+ this.exploration = exploracao;
73
118
  this.turns = turns;
74
119
  this.action = action || null;
75
120
  this.toolCalls = toolCalls;
121
+ this.toolSignatures = toolSignatures;
76
122
  }
77
123
  }
78
124
 
@@ -22,7 +22,7 @@ import {
22
22
  toolDefinitionsFor,
23
23
  } from './tools.mjs';
24
24
  import { withTrace, startObservation, updateSpan, endObservation } from './tracing.mjs';
25
- import { TruncatedOutputError, isTruncationReason, summarizeToolCalls } from './errors.mjs';
25
+ import { TruncatedOutputError, isTruncationReason, summarizeToolCalls, toolSignature } from './errors.mjs';
26
26
 
27
27
  const DEFAULT_BASE_URL = 'https://openrouter.ai/api/v1';
28
28
  const DEFAULT_TOOLS = ['Read', 'Glob', 'Grep'];
@@ -184,6 +184,7 @@ export async function runOpenRouterAgent(prompt, options) {
184
184
  // turnos sem escrever nada, o log não dizia no que ele se perdeu, e o
185
185
  // diagnóstico exigiu reconstruir o run à mão.
186
186
  toolCalls: [],
187
+ toolSignatures: [],
187
188
  };
188
189
  const quiet = options.quiet !== false;
189
190
 
@@ -333,6 +334,9 @@ export async function runOpenRouterAgent(prompt, options) {
333
334
  }
334
335
 
335
336
  runResult.toolCalls.push(call.function.name);
337
+ // Assinatura (nome + alvo) separa exploração de loop degenerado —
338
+ // é o que decide se o teto de turnos merece uma repetição.
339
+ runResult.toolSignatures.push(toolSignature(call.function.name, parsedInput));
336
340
  if (options.verbose) console.log(`\n[Tool] ${call.function.name}(${call.function.arguments})`);
337
341
 
338
342
  const observation = startObservation(
@@ -179,17 +179,137 @@ export async function getProjectSnapshot(token, projectId) {
179
179
  };
180
180
  }
181
181
 
182
- // Adiciona uma issue/PR (pelo node id do conteúdo) ao Project. Retorna o id do item criado.
183
- export async function addProjectItem(token, projectId, contentId) {
182
+ /**
183
+ * O item deste conteúdo que JÁ está no project (função de rede, sem mutação).
184
+ *
185
+ * Vai pelo conteúdo (`node(contentId).projectItems`), não pelos itens do
186
+ * project: um board com milhares de itens paginaria por todos eles para achar
187
+ * um; uma issue está em poucos projects.
188
+ *
189
+ * @returns {Promise<string|null>} id do item, ou null se não estiver no project
190
+ */
191
+ export async function findProjectItem(token, projectId, contentId) {
184
192
  const client = makeClient(token);
185
193
  const result = await client(`
186
- mutation AddItem($projectId: ID!, $contentId: ID!) {
187
- addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) {
188
- item { id }
194
+ query FindItem($contentId: ID!) {
195
+ node(id: $contentId) {
196
+ ... on Issue { projectItems(first: 50) { nodes { id project { id } } } }
197
+ ... on PullRequest { projectItems(first: 50) { nodes { id project { id } } } }
198
+ }
199
+ }
200
+ `, { contentId });
201
+ const nodes = result?.node?.projectItems?.nodes || [];
202
+ return nodes.find(n => n?.project?.id === projectId)?.id ?? null;
203
+ }
204
+
205
+ /**
206
+ * Adiciona uma issue/PR (pelo node id do conteúdo) ao Project e devolve o id do
207
+ * item. IDEMPOTENTE: item já presente devolve o id existente.
208
+ *
209
+ * `addProjectV2ItemById` é documentado como idempotente, mas na prática responde
210
+ * `Content already exists in this project` quando o item entrou por outro
211
+ * caminho entre a leitura e a escrita — a automação nativa "auto-add" do
212
+ * project, ou um run concorrente do próprio spec-wave. Como esta é a PRIMEIRA
213
+ * chamada de advanceToStage(), o erro abortava tudo o que vinha depois: o item
214
+ * ficava no board com o Status que a automação do project escreve e SEM Etapa —
215
+ * exatamente o estado que some de todas as telas (caso da #281). Traduzir o erro
216
+ * numa busca pelo item existente é o que torna a escrita seguinte possível.
217
+ */
218
+ export async function addProjectItem(token, projectId, contentId) {
219
+ const client = makeClient(token);
220
+ try {
221
+ const result = await client(`
222
+ mutation AddItem($projectId: ID!, $contentId: ID!) {
223
+ addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) {
224
+ item { id }
225
+ }
226
+ }
227
+ `, { projectId, contentId });
228
+ return result.addProjectV2ItemById.item.id;
229
+ } catch (err) {
230
+ if (!isAlreadyInProjectError(err)) throw err;
231
+ const existing = await findProjectItem(token, projectId, contentId);
232
+ // Sem item correspondente, o erro não era duplicidade — devolvê-lo é mais
233
+ // honesto que um null que estouraria adiante como "field id inválido".
234
+ if (!existing) throw err;
235
+ return existing;
236
+ }
237
+ }
238
+
239
+ /**
240
+ * O erro é "esse conteúdo já está no project"? (função PURA)
241
+ *
242
+ * A mensagem vem de fora e pode mudar; o teste é por substring e não por
243
+ * igualdade justamente por isso. Errar para o lado de NÃO reconhecer só
244
+ * restaura o comportamento antigo (o erro sobe).
245
+ */
246
+ export function isAlreadyInProjectError(err) {
247
+ const msg = String(err?.message || '');
248
+ return /already exists in this project/i.test(msg)
249
+ || /content already exists/i.test(msg);
250
+ }
251
+
252
+ /**
253
+ * Todos os itens do Project, com os campos SINGLE_SELECT resolvidos por NOME.
254
+ *
255
+ * Uma query paginada no lugar de duas chamadas POR ITEM
256
+ * (`addProjectItem` + `getItemSingleSelectValue`), que é como o `order` lia a
257
+ * Etapa de cada Story. Com nove Features na tela isso passava de cem chamadas
258
+ * para montar um mapa que cabe em duas.
259
+ *
260
+ * Só itens cujo conteúdo é Issue entram (draft e PR ficam de fora).
261
+ *
262
+ * @returns {Promise<Array<{number, title, state, nodeId, itemId, fields: Record<string,string>}>>}
263
+ */
264
+ export async function listProjectItems(token, projectId) {
265
+ const client = makeClient(token);
266
+ const itens = [];
267
+ let after = null;
268
+ do {
269
+ const result = await client(`
270
+ query ProjectItems($id: ID!, $after: String) {
271
+ node(id: $id) {
272
+ ... on ProjectV2 {
273
+ items(first: 100, after: $after) {
274
+ pageInfo { hasNextPage endCursor }
275
+ nodes {
276
+ id
277
+ fieldValues(first: 20) {
278
+ nodes {
279
+ ... on ProjectV2ItemFieldSingleSelectValue {
280
+ name
281
+ field { ... on ProjectV2SingleSelectField { name } }
282
+ }
283
+ }
284
+ }
285
+ content { ... on Issue { number title state id } }
286
+ }
287
+ }
288
+ }
289
+ }
290
+ }
291
+ `, { id: projectId, after });
292
+ const page = result.node?.items;
293
+ for (const node of page?.nodes || []) {
294
+ const issue = node?.content;
295
+ if (!issue?.number) continue;
296
+ const fields = {};
297
+ for (const fv of node.fieldValues?.nodes || []) {
298
+ const nome = fv?.field?.name;
299
+ if (nome && fv.name) fields[nome] = fv.name;
189
300
  }
301
+ itens.push({
302
+ number: issue.number,
303
+ title: issue.title,
304
+ state: issue.state,
305
+ nodeId: issue.id,
306
+ itemId: node.id,
307
+ fields,
308
+ });
190
309
  }
191
- `, { projectId, contentId });
192
- return result.addProjectV2ItemById.item.id;
310
+ after = page?.pageInfo?.hasNextPage ? page.pageInfo.endCursor : null;
311
+ } while (after);
312
+ return itens;
193
313
  }
194
314
 
195
315
  // Cria a relação de sub-issue nativa do GitHub (parent → child). Ambos os IDs
@@ -281,9 +281,16 @@ export async function ensurePullRequest(
281
281
 
282
282
  // `id` é o database id da issue — exigido pela API de dependências
283
283
  // (blocked_by), que não aceita number nem node id.
284
- export async function createIssue(token, owner, repo, title, body, labels) {
284
+ //
285
+ // `milestone` é o NÚMERO do milestone no repo (não o título nem o node id) e só
286
+ // entra no payload quando existe: a API rejeita `milestone: null` com 422 em vez
287
+ // de tratar como "sem milestone".
288
+ export async function createIssue(token, owner, repo, title, body, labels, { milestone } = {}) {
285
289
  const octokit = makeOctokit(token);
286
- const res = await octokit.rest.issues.create({ owner, repo, title, body, labels });
290
+ const res = await octokit.rest.issues.create({
291
+ owner, repo, title, body, labels,
292
+ ...(milestone ? { milestone } : {}),
293
+ });
287
294
  return { number: res.data.number, nodeId: res.data.node_id, url: res.data.html_url, id: res.data.id };
288
295
  }
289
296
 
@@ -282,6 +282,9 @@ export async function codeReview({ prNumber, resolveOnly = false }) {
282
282
  // Resolve campos uma vez, reutiliza em todas as Features.
283
283
  const etapaField = await resolveField(projectToken, project, 'Etapa').catch(() => null);
284
284
  const statusField = await resolveField(projectToken, project, 'Status').catch(() => null);
285
+ // Repara o Work Item Type vazio de passagem (o apply nunca o escreveu).
286
+ // Só preenche o vazio e nunca derruba o movimento — ver ensureWorkItemType.
287
+ const typeField = await resolveField(projectToken, project, 'Work Item Type').catch(() => null);
285
288
 
286
289
  const seen = new Set();
287
290
  const updated = [];
@@ -312,7 +315,9 @@ export async function codeReview({ prNumber, resolveOnly = false }) {
312
315
  if (seen.has(n)) continue;
313
316
  seen.add(n);
314
317
  try {
315
- const moved = await advanceToStage(projectToken, project, etapaField, statusField, info.nodeId, DONE_STAGE, DONE_STATUS);
318
+ const moved = await advanceToStage(
319
+ projectToken, project, etapaField, statusField, info.nodeId, DONE_STAGE, DONE_STATUS,
320
+ { typeField, itemType: 'Task' });
316
321
  if (moved) {
317
322
  updated.push(`#${n} ${info.title} → ${DONE_STAGE}`);
318
323
  console.log(`#${n} → "${DONE_STAGE}" / Status "${DONE_STATUS}".`);
@@ -329,7 +334,9 @@ export async function codeReview({ prNumber, resolveOnly = false }) {
329
334
  if (seen.has(n)) continue;
330
335
  seen.add(n);
331
336
  try {
332
- const moved = await advanceToStage(projectToken, project, etapaField, statusField, info.nodeId, CODE_REVIEW_STAGE, TODO_STATUS);
337
+ const moved = await advanceToStage(
338
+ projectToken, project, etapaField, statusField, info.nodeId, CODE_REVIEW_STAGE, TODO_STATUS,
339
+ { typeField, itemType: 'Story' });
333
340
  if (moved) {
334
341
  updated.push(`#${n} ${info.title} → ${CODE_REVIEW_STAGE}`);
335
342
  console.log(`#${n} → "${CODE_REVIEW_STAGE}" / Status "${TODO_STATUS}".`);
@@ -346,7 +353,9 @@ export async function codeReview({ prNumber, resolveOnly = false }) {
346
353
  if (seen.has(n)) continue;
347
354
  seen.add(n);
348
355
  try {
349
- const moved = await advanceToStage(projectToken, project, etapaField, statusField, info.nodeId, CODE_REVIEW_STAGE, TODO_STATUS);
356
+ const moved = await advanceToStage(
357
+ projectToken, project, etapaField, statusField, info.nodeId, CODE_REVIEW_STAGE, TODO_STATUS,
358
+ { typeField, itemType: 'Bug' });
350
359
  if (moved) {
351
360
  updated.push(`#${n} ${info.title} → ${CODE_REVIEW_STAGE}`);
352
361
  console.log(`Bug #${n} → "${CODE_REVIEW_STAGE}" / Status "${TODO_STATUS}".`);
@@ -367,7 +376,9 @@ export async function codeReview({ prNumber, resolveOnly = false }) {
367
376
  console.log(`Feature #${feature.number} mantida em desenvolvimento — ainda há Stories pendentes (fora de "${CODE_REVIEW_STAGE}").`);
368
377
  } else if (!seen.has(feature.number)) {
369
378
  seen.add(feature.number);
370
- const moved = await advanceToStage(projectToken, project, etapaField, statusField, feature.nodeId, CODE_REVIEW_STAGE, TODO_STATUS);
379
+ const moved = await advanceToStage(
380
+ projectToken, project, etapaField, statusField, feature.nodeId, CODE_REVIEW_STAGE, TODO_STATUS,
381
+ { typeField, itemType: 'Feature' });
371
382
  if (moved) {
372
383
  updated.push(`#${feature.number} ${feature.title} (Feature) → ${CODE_REVIEW_STAGE}`);
373
384
  console.log(`Feature #${feature.number} → "${CODE_REVIEW_STAGE}" (todas as Stories concluídas).`);