@spec-wave/cli 0.21.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 +63 -0
- package/src/api/github-rest.mjs +9 -2
- package/src/commands/decompose.mjs +82 -5
- package/src/commands/move.mjs +52 -0
- package/src/commands/order.mjs +200 -7
- package/src/commands/validate.mjs +39 -18
- package/src/config.mjs +9 -3
- package/src/lib/board.mjs +34 -1
- 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/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/bin/spec-wave.mjs
CHANGED
|
@@ -263,8 +263,8 @@ program
|
|
|
263
263
|
|
|
264
264
|
program
|
|
265
265
|
.command('order')
|
|
266
|
-
.description('Ordena as Stories
|
|
267
|
-
.argument('
|
|
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
|
@@ -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
|
}
|
package/src/agent/errors.mjs
CHANGED
|
@@ -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.
|
|
50
|
-
//
|
|
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
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
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(
|
|
@@ -249,6 +249,69 @@ export function isAlreadyInProjectError(err) {
|
|
|
249
249
|
|| /content already exists/i.test(msg);
|
|
250
250
|
}
|
|
251
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;
|
|
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
|
+
});
|
|
309
|
+
}
|
|
310
|
+
after = page?.pageInfo?.hasNextPage ? page.pageInfo.endCursor : null;
|
|
311
|
+
} while (after);
|
|
312
|
+
return itens;
|
|
313
|
+
}
|
|
314
|
+
|
|
252
315
|
// Cria a relação de sub-issue nativa do GitHub (parent → child). Ambos os IDs
|
|
253
316
|
// são node ids de Issue. Faz com que o filho exiba o pai e vice-versa na UI.
|
|
254
317
|
export async function addSubIssue(token, parentIssueId, childIssueId) {
|
package/src/api/github-rest.mjs
CHANGED
|
@@ -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
|
-
|
|
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({
|
|
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
|
|
|
@@ -203,6 +203,25 @@ export function shouldSkipDecompose({ labels = [], subIssues = [], type } = {})
|
|
|
203
203
|
return { skip: false, reason: '' };
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Milestone que os filhos herdam do pai (função PURA — testável).
|
|
208
|
+
*
|
|
209
|
+
* Uma Story/Task nasce para entregar a Feature que a gerou: separá-las da release
|
|
210
|
+
* do pai faria a mesma entrega aparecer em dois grupos — e o milestone é o que o
|
|
211
|
+
* fluxo usa como release (é o milestone fechado que sinaliza o deploy). Mesma
|
|
212
|
+
* regra que a reprovação de QA já aplica ao registrar um bug filho.
|
|
213
|
+
*
|
|
214
|
+
* Devolve o NÚMERO (o que `createIssue` aceita) ou `undefined` quando o pai não
|
|
215
|
+
* tem milestone — aí não há release a herdar, e o filho nasce sem, como antes.
|
|
216
|
+
*
|
|
217
|
+
* @param {{ milestone?: { number?: number, title?: string } | null }} [issue]
|
|
218
|
+
* @returns {number|undefined}
|
|
219
|
+
*/
|
|
220
|
+
export function resolveInheritedMilestone(issue) {
|
|
221
|
+
const number = issue?.milestone?.number;
|
|
222
|
+
return Number.isInteger(number) && number > 0 ? number : undefined;
|
|
223
|
+
}
|
|
224
|
+
|
|
206
225
|
// Diretório do documento por tipo. Feature usa o mesmo docs/features/<slug> da
|
|
207
226
|
// spec/plan; RFC ganha o seu, já que não passa por spec/plan.
|
|
208
227
|
function resolveDocDir(root, issue, type) {
|
|
@@ -560,10 +579,59 @@ function failIfBoardIncomplete(failures) {
|
|
|
560
579
|
throw err;
|
|
561
580
|
}
|
|
562
581
|
|
|
582
|
+
// Linha do comentário registrando o milestone herdado ('' quando não há).
|
|
583
|
+
function milestoneLine(issue, milestone) {
|
|
584
|
+
if (!milestone) return '';
|
|
585
|
+
return `🏁 Milestone herdado do pai: **${issue.milestone?.title ?? `#${milestone}`}**.\n\n`;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Resolve as dependências externas do rascunho ANTES de criar qualquer issue.
|
|
590
|
+
*
|
|
591
|
+
* `#N` só entra no documento se a issue JÁ existe (fase 1 da proposta), e é
|
|
592
|
+
* aqui que isso é cobrado. Falhar cedo é a única opção honesta: descobrir no
|
|
593
|
+
* meio do laço que a #412 não existe deixaria metade das Stories criadas e a
|
|
594
|
+
* outra metade não — o estado que o apply inteiro foi desenhado para evitar.
|
|
595
|
+
*
|
|
596
|
+
* `addBlockedBy` exige o DATABASE id da bloqueadora (não aceita number nem node
|
|
597
|
+
* id), e é por isso que resolver custa uma leitura por issue distinta.
|
|
598
|
+
*
|
|
599
|
+
* @returns {Promise<Map<number, {number:number, id:number, title:string}>>}
|
|
600
|
+
*/
|
|
601
|
+
async function resolveExternalDeps({ token, owner, repo }, doc) {
|
|
602
|
+
const numeros = [...new Set(
|
|
603
|
+
(doc.stories || []).flatMap(s => s.dependsOnIssues || [])
|
|
604
|
+
)].sort((a, b) => a - b);
|
|
605
|
+
const resolvidas = new Map();
|
|
606
|
+
const faltando = [];
|
|
607
|
+
for (const n of numeros) {
|
|
608
|
+
try {
|
|
609
|
+
const issue = await getIssue(token, owner, repo, n);
|
|
610
|
+
resolvidas.set(n, { number: issue.number, id: issue.id, title: issue.title });
|
|
611
|
+
} catch (err) {
|
|
612
|
+
faltando.push(`#${n} (${err.status === 404 ? 'não existe' : err.message})`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
if (faltando.length > 0) {
|
|
616
|
+
throw new Error(
|
|
617
|
+
`o rascunho depende de issue(s) que não consegui ler: ${faltando.join(', ')}. ` +
|
|
618
|
+
'Em "**Depende de:**", `#N` precisa ser uma issue que JÁ existe — corrija o ' +
|
|
619
|
+
'decomposition.md (ou aplique antes a Feature que cria essa Story).'
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
return resolvidas;
|
|
623
|
+
}
|
|
624
|
+
|
|
563
625
|
async function createStoriesFromDoc(ctx, doc) {
|
|
564
626
|
const { token, projectToken, owner, repo, issue, issueNumber, project, docRel } = ctx;
|
|
565
627
|
const fields = { etapaField: ctx.etapaField, statusField: ctx.statusField, typeField: ctx.typeField };
|
|
566
628
|
const featureNodeId = issue.node_id;
|
|
629
|
+
// Herdado uma vez e reusado por todas as Stories e Tasks deste apply.
|
|
630
|
+
const milestone = resolveInheritedMilestone(issue);
|
|
631
|
+
if (milestone) console.log(`Milestone herdado da ${ctx.type}: ${issue.milestone?.title ?? `#${milestone}`}.`);
|
|
632
|
+
// Antes da primeira criação: rascunho que aponta para issue inexistente é
|
|
633
|
+
// erro de escrita, e o lugar de reprovar é aqui.
|
|
634
|
+
const externas = await resolveExternalDeps(ctx, doc);
|
|
567
635
|
const created = [];
|
|
568
636
|
const createdStories = []; // issues criadas, na ordem dos índices das stories
|
|
569
637
|
const generatedTexts = []; // títulos+corpos para o lint de idioma final
|
|
@@ -580,12 +648,17 @@ async function createStoriesFromDoc(ctx, doc) {
|
|
|
580
648
|
.filter(Boolean)
|
|
581
649
|
.join('\n\n') || '_(sem descrição)_';
|
|
582
650
|
|
|
583
|
-
//
|
|
584
|
-
|
|
651
|
+
// Irmãs (índices já validados pelo parser, sempre para trás) + externas (já
|
|
652
|
+
// resolvidas acima). As duas viram a MESMA linha no corpo e a mesma relação
|
|
653
|
+
// blocked_by: para quem lê a issue, a fronteira da Feature não existe.
|
|
654
|
+
const depIssues = [
|
|
655
|
+
...story.dependsOn.map(idx => createdStories[idx]),
|
|
656
|
+
...(story.dependsOnIssues || []).map(n => externas.get(n)),
|
|
657
|
+
].filter(Boolean);
|
|
585
658
|
const depLine = formatDependencyLine(depIssues.map(d => d.number));
|
|
586
659
|
if (depLine) storyBody += `\n\n${depLine}`;
|
|
587
660
|
|
|
588
|
-
const createdStory = await createIssue(token, owner, repo, storyTitle, storyBody, ['[STORY]']);
|
|
661
|
+
const createdStory = await createIssue(token, owner, repo, storyTitle, storyBody, ['[STORY]'], { milestone });
|
|
589
662
|
ctx.createdItems.push(createdStory.number);
|
|
590
663
|
// Anota no doc em memória: é daqui que sai o `**Issue:** #N` gravado no
|
|
591
664
|
// arquivo no fim do apply.
|
|
@@ -619,7 +692,7 @@ async function createStoriesFromDoc(ctx, doc) {
|
|
|
619
692
|
console.log(` Criando task: ${task.title}`);
|
|
620
693
|
const taskTitle = `[TASK] ${task.title}`;
|
|
621
694
|
const taskBody = `${task.body}\n\n_Story pai: ${createdStory.url}_`;
|
|
622
|
-
const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]']);
|
|
695
|
+
const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]'], { milestone });
|
|
623
696
|
ctx.createdItems.push(createdTask.number);
|
|
624
697
|
task.issue = createdTask.number;
|
|
625
698
|
generatedTexts.push(taskTitle, taskBody);
|
|
@@ -652,6 +725,7 @@ async function createStoriesFromDoc(ctx, doc) {
|
|
|
652
725
|
await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10),
|
|
653
726
|
`🔀 **Decomposição aplicada!**\n\n` +
|
|
654
727
|
`A partir de \`${docRel}\` foram criados ${created.length} stories e suas tasks:\n\n${list}\n\n` +
|
|
728
|
+
milestoneLine(issue, milestone) +
|
|
655
729
|
posicionamento +
|
|
656
730
|
formatItemsLintWarning(generatedTexts)
|
|
657
731
|
).catch(err => console.warn(`Falha ao comentar a decomposição: ${err.message}`));
|
|
@@ -710,6 +784,8 @@ async function createTasksFromDoc(ctx, doc) {
|
|
|
710
784
|
const { token, projectToken, owner, repo, issue, issueNumber, project, docRel } = ctx;
|
|
711
785
|
const fields = { etapaField: ctx.etapaField, statusField: ctx.statusField, typeField: ctx.typeField };
|
|
712
786
|
const parentNodeId = issue.node_id;
|
|
787
|
+
const milestone = resolveInheritedMilestone(issue);
|
|
788
|
+
if (milestone) console.log(`Milestone herdado do RFC: ${issue.milestone?.title ?? `#${milestone}`}.`);
|
|
713
789
|
const created = [];
|
|
714
790
|
const generatedTexts = [];
|
|
715
791
|
const boardFailures = [];
|
|
@@ -718,7 +794,7 @@ async function createTasksFromDoc(ctx, doc) {
|
|
|
718
794
|
console.log(`Criando task: ${task.title}`);
|
|
719
795
|
const taskTitle = `[TASK] ${task.title}`;
|
|
720
796
|
const taskBody = `${task.body}\n\n_RFC pai: ${issue.html_url || `#${issueNumber}`}_`;
|
|
721
|
-
const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]']);
|
|
797
|
+
const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]'], { milestone });
|
|
722
798
|
ctx.createdItems.push(createdTask.number);
|
|
723
799
|
task.issue = createdTask.number;
|
|
724
800
|
created.push({ title: taskTitle, url: createdTask.url });
|
|
@@ -744,6 +820,7 @@ async function createTasksFromDoc(ctx, doc) {
|
|
|
744
820
|
await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10),
|
|
745
821
|
`🔀 **Decomposição do RFC aplicada!**\n\n` +
|
|
746
822
|
`A partir de \`${docRel}\` foram criadas ${created.length} tasks:\n\n${list}\n\n` +
|
|
823
|
+
milestoneLine(issue, milestone) +
|
|
747
824
|
posicionamento +
|
|
748
825
|
formatItemsLintWarning(generatedTexts)
|
|
749
826
|
).catch(err => console.warn(`Falha ao comentar a decomposição: ${err.message}`));
|
package/src/commands/move.mjs
CHANGED
|
@@ -21,6 +21,8 @@ import { loadConfig } from '../lib/project-root.mjs';
|
|
|
21
21
|
import {
|
|
22
22
|
CONFIG_FILE, PROGRESS_TODO, PROGRESS_IN_PROGRESS, PROGRESS_DONE,
|
|
23
23
|
isManualStageType, MANUAL_STAGE_TYPES, isStageInTrack, STAGE_TRACKS,
|
|
24
|
+
STAGE_READY, LABEL_PLAN_APPROVED, LABEL_SPEC, LABEL_PLAN, LABEL_CRITIQUE_FAILED,
|
|
25
|
+
LABEL_NEEDS_HUMAN, allowsSpecPlan, labelNames,
|
|
24
26
|
} from '../config.mjs';
|
|
25
27
|
|
|
26
28
|
const PROGRESS_VALUES = [PROGRESS_TODO, PROGRESS_IN_PROGRESS, PROGRESS_DONE];
|
|
@@ -49,6 +51,51 @@ export function resolveProgressName(input) {
|
|
|
49
51
|
};
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
/**
|
|
55
|
+
* O que o board sabe e quem move à mão pode não saber (função PURA).
|
|
56
|
+
*
|
|
57
|
+
* `advanceToStage` já protege a ORDEM das etapas ("a Etapa nunca retrocede"),
|
|
58
|
+
* mas nada olhava os PORTÕES: mover uma Feature para ✅ Ready sem
|
|
59
|
+
* `spec-wave:plan-approved` põe na fila do time um item cuja validação não
|
|
60
|
+
* passou — e com `spec-wave:spec` ainda na issue, que é o rastro de uma reprova
|
|
61
|
+
* recente. Foi o que aconteceu com a #43.
|
|
62
|
+
*
|
|
63
|
+
* AVISA, não bloqueia: a decisão pode ser deliberada (documentos corrigidos à
|
|
64
|
+
* mão, validação que não se quer rodar de novo), e o comando existe justamente
|
|
65
|
+
* para os casos que o fluxo não cobre. O que não pode é ser silencioso.
|
|
66
|
+
*
|
|
67
|
+
* @param {object} params
|
|
68
|
+
* @param {string|null} params.type tipo do work item
|
|
69
|
+
* @param {string} params.stage etapa de destino
|
|
70
|
+
* @param {Array<string|{name:string}>} [params.labels] labels da issue
|
|
71
|
+
* @returns {string[]} avisos, na ordem de gravidade
|
|
72
|
+
*/
|
|
73
|
+
export function readinessWarnings({ type, stage, labels = [] } = {}) {
|
|
74
|
+
if (stage !== STAGE_READY) return [];
|
|
75
|
+
const nomes = labelNames(labels);
|
|
76
|
+
const avisos = [];
|
|
77
|
+
|
|
78
|
+
// Bug, RFC e Spike não passam por spec/plan — cobrar plan-approved deles
|
|
79
|
+
// seria inventar um portão que o fluxo não tem.
|
|
80
|
+
if (allowsSpecPlan(type) && !nomes.includes(LABEL_PLAN_APPROVED)) {
|
|
81
|
+
avisos.push(
|
|
82
|
+
`não tem \`${LABEL_PLAN_APPROVED}\`: spec+plan não passaram pelo \`validate\`. ` +
|
|
83
|
+
`Para validar, aplique \`spec-wave:ready\` na issue.`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
for (const gatilho of [LABEL_SPEC, LABEL_PLAN]) {
|
|
87
|
+
if (nomes.includes(gatilho)) {
|
|
88
|
+
avisos.push(`ainda tem \`${gatilho}\` — há geração de documento pendente ou uma reprova recente.`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
for (const portao of [LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN]) {
|
|
92
|
+
if (nomes.includes(portao)) {
|
|
93
|
+
avisos.push(`tem \`${portao}\` — a crítica reprovou e ninguém liberou.`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return avisos;
|
|
97
|
+
}
|
|
98
|
+
|
|
52
99
|
export async function move({ issue: issueArg, stage: stageArg, status: statusArg }) {
|
|
53
100
|
const issueNumber = parseInt(String(issueArg).replace('#', ''), 10);
|
|
54
101
|
if (!Number.isInteger(issueNumber) || issueNumber <= 0) {
|
|
@@ -126,6 +173,11 @@ export async function move({ issue: issueArg, stage: stageArg, status: statusArg
|
|
|
126
173
|
);
|
|
127
174
|
}
|
|
128
175
|
|
|
176
|
+
// Portões do fluxo: aviso antes de escrever, com a issue já em mãos.
|
|
177
|
+
for (const aviso of readinessWarnings({ type, stage, labels: issue.labels })) {
|
|
178
|
+
p.log.warn(`#${issueNumber} ${aviso}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
129
181
|
const { project, error: projectError } = loadProjectConfig({ cwd: root || process.cwd() });
|
|
130
182
|
if (projectError) {
|
|
131
183
|
p.log.error(`${projectError} — board não atualizado. Rode \`spec-wave init\` (ou \`spec-wave refresh --config\`).`);
|