@spec-wave/cli 0.18.0 → 0.19.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 +34 -3
- package/package.json +1 -1
- package/src/agent/anthropic-agent.mjs +10 -1
- package/src/agent/errors.mjs +57 -3
- package/src/agent/openrouter-agent.mjs +12 -2
- package/src/api/auth.mjs +217 -9
- package/src/api/github-graphql.mjs +33 -0
- package/src/commands/code-review.mjs +186 -31
- package/src/commands/decompose.mjs +151 -13
- package/src/commands/doctor.mjs +195 -35
- package/src/commands/generate-bug.mjs +18 -15
- package/src/commands/generate-plan.mjs +94 -6
- package/src/commands/generate-spec.mjs +7 -4
- package/src/commands/repair-stage.mjs +232 -0
- package/src/commands/update.mjs +13 -5
- package/src/config.mjs +43 -0
- package/src/lib/board.mjs +44 -0
- package/src/lib/claude.mjs +59 -9
- package/src/lib/critique.mjs +186 -14
- package/src/lib/decomposition-doc.mjs +59 -7
- package/src/lib/flow-run.mjs +134 -5
- package/src/lib/prompt-loader.mjs +30 -3
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/skills/move/SKILL.md +29 -0
- package/src/plugin/skills/plan/SKILL.md +13 -0
- package/src/plugin/skills/spec/model-prompt.critique.md +58 -0
- package/src/setup/labels.mjs +10 -6
- package/src/templates/workflows/code-review.yml +34 -1
- package/src/templates/workflows/decompose.yml +12 -1
- package/src/templates/workflows/generate-bug.yml +8 -1
- package/src/templates/workflows/generate-plan.yml +16 -1
- package/src/templates/workflows/generate-spec.yml +16 -1
package/src/lib/critique.mjs
CHANGED
|
@@ -42,11 +42,15 @@ const MAX_FINDING_CHARS = 800;
|
|
|
42
42
|
export const CRITIQUE_TOOL_NAME = 'registrar_findings';
|
|
43
43
|
|
|
44
44
|
// Rótulo do artefato auditado, por contexto — usado no cabeçalho do comentário.
|
|
45
|
-
const KIND_LABEL = {
|
|
45
|
+
const KIND_LABEL = {
|
|
46
|
+
plan: 'plan.md', stories: 'decomposition.md', bug: 'bug.md', spec: 'spec.md',
|
|
47
|
+
};
|
|
46
48
|
|
|
47
49
|
// Prompt por tipo de auditoria. 'plan' audita o plan.md contra a spec;
|
|
48
50
|
// 'stories' audita a decomposição proposta contra spec + plan.
|
|
49
|
-
const KIND_PROMPT = {
|
|
51
|
+
const KIND_PROMPT = {
|
|
52
|
+
plan: 'plan/critique', stories: 'decompose/critique', bug: 'bug/critique', spec: 'spec/critique',
|
|
53
|
+
};
|
|
50
54
|
|
|
51
55
|
// A decomposição virou arquivo revisável (decomposition.md): um finding só é
|
|
52
56
|
// acionável se disser ONDE está o problema. Os títulos "## Story N" e
|
|
@@ -83,6 +87,15 @@ Classifique cada finding no campo "severity", usando EXATAMENTE um destes dois v
|
|
|
83
87
|
- "grave": contradiz um requisito ou regra explícita — causaria implementação errada;
|
|
84
88
|
- "menor": inconsistência, omissão ou ambiguidade que merece atenção mas não inverte requisito.
|
|
85
89
|
|
|
90
|
+
Todo finding "grave" DEVE trazer, no campo "quote", um trecho LITERAL do documento auditado
|
|
91
|
+
(copiado byte a byte, 10 a 200 caracteres) onde o problema está. Grave sem citação verificável é
|
|
92
|
+
rebaixado para "menor" automaticamente — não porque o achado seja falso, mas porque bloquear o
|
|
93
|
+
fluxo exige poder apontar onde.
|
|
94
|
+
|
|
95
|
+
Antes de marcar "grave", releia o que você escreveu: se o próprio texto conclui que não há
|
|
96
|
+
contradição ("consistente com", "sem contradição real", "está correto"), então o finding é "menor"
|
|
97
|
+
ou não é finding.
|
|
98
|
+
|
|
86
99
|
Escreva os findings em português (pt-BR), em uma frase objetiva cada.${anchor}
|
|
87
100
|
|
|
88
101
|
Registre o resultado chamando a ferramenta \`${CRITIQUE_TOOL_NAME}\`. Nunca responda em texto livre.
|
|
@@ -106,6 +119,12 @@ function critiqueJsonSchema(kind) {
|
|
|
106
119
|
type: 'string',
|
|
107
120
|
description: 'Descrição do finding em português (pt-BR), em uma única frase objetiva.',
|
|
108
121
|
},
|
|
122
|
+
quote: {
|
|
123
|
+
type: 'string',
|
|
124
|
+
description:
|
|
125
|
+
'Trecho LITERAL do documento auditado (10–200 caracteres) onde o problema está. ' +
|
|
126
|
+
'Obrigatório em findings "grave" — sem ele o finding é rebaixado para "menor".',
|
|
127
|
+
},
|
|
109
128
|
};
|
|
110
129
|
const required = ['severity', 'text'];
|
|
111
130
|
if (kind === 'stories') {
|
|
@@ -225,7 +244,8 @@ export function validateCritiquePayload(payload) {
|
|
|
225
244
|
'O campo com a descrição do finding DEVE se chamar "text".'
|
|
226
245
|
);
|
|
227
246
|
}
|
|
228
|
-
const extra = itemKeys.filter(
|
|
247
|
+
const extra = itemKeys.filter(
|
|
248
|
+
k => k !== 'severity' && k !== 'text' && k !== 'anchor' && k !== 'quote');
|
|
229
249
|
if (extra.length > 0) {
|
|
230
250
|
throw new CritiqueSchemaError(`${at} tem campo(s) não reconhecido(s): ${extra.join(', ')}.`);
|
|
231
251
|
}
|
|
@@ -244,12 +264,122 @@ export function validateCritiquePayload(payload) {
|
|
|
244
264
|
);
|
|
245
265
|
}
|
|
246
266
|
const anchor = normalizeAnchor(item.anchor);
|
|
247
|
-
|
|
267
|
+
const quote = typeof item.quote === 'string' ? item.quote.trim() : '';
|
|
268
|
+
return {
|
|
269
|
+
severity,
|
|
270
|
+
...(anchor ? { anchor } : {}),
|
|
271
|
+
...(quote ? { quote } : {}),
|
|
272
|
+
text: item.text.trim(),
|
|
273
|
+
};
|
|
248
274
|
});
|
|
249
275
|
|
|
250
276
|
return { grave: findings.some(f => f.severity === 'grave'), findings };
|
|
251
277
|
}
|
|
252
278
|
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
// Coerência: um "grave" tem de se sustentar sozinho
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
//
|
|
283
|
+
// Na segunda rodada do decompose da EP1-F8, o único achado grave terminava com
|
|
284
|
+
// "consistente na Story, sem contradição real" — o modelo classificou como grave
|
|
285
|
+
// algo que ele mesmo concluiu não existir. Grave faz o Action sair com 1 e
|
|
286
|
+
// aplicar `spec-wave:critique-failed`: aquele parágrafo custou uma rodada
|
|
287
|
+
// inteira do fluxo.
|
|
288
|
+
//
|
|
289
|
+
// A checagem aqui é DETERMINÍSTICA e barata (sem IA):
|
|
290
|
+
// 1. o texto do finding se auto-refuta?
|
|
291
|
+
// 2. a citação existe mesmo no documento auditado?
|
|
292
|
+
// Nenhuma das duas julga o MÉRITO do achado — as duas julgam se ele se sustenta
|
|
293
|
+
// como bloqueio. Quando não se sustenta, o finding é REBAIXADO para menor com o
|
|
294
|
+
// motivo à vista, nunca descartado: apagar achado em silêncio foi o vício da
|
|
295
|
+
// versão antiga da crítica, e o remédio não pode reintroduzi-lo.
|
|
296
|
+
|
|
297
|
+
// Frases em que o modelo desmente o próprio finding. Deliberadamente curtas e
|
|
298
|
+
// literais: heurística ampla aqui rebaixaria achado legítimo, e o custo de um
|
|
299
|
+
// falso rebaixamento (grave que deixa de bloquear) é maior que o de um falso
|
|
300
|
+
// grave (uma rodada perdida, com o TL podendo decidir).
|
|
301
|
+
const SELF_REFUTING = [
|
|
302
|
+
'sem contradição real',
|
|
303
|
+
'sem contradicao real',
|
|
304
|
+
'não há contradição',
|
|
305
|
+
'nao ha contradicao',
|
|
306
|
+
'não existe contradição',
|
|
307
|
+
'nao existe contradicao',
|
|
308
|
+
'sem inconsistência real',
|
|
309
|
+
'sem inconsistencia real',
|
|
310
|
+
'não há inconsistência',
|
|
311
|
+
'nao ha inconsistencia',
|
|
312
|
+
'está correto',
|
|
313
|
+
'esta correto',
|
|
314
|
+
'está coerente',
|
|
315
|
+
'esta coerente',
|
|
316
|
+
'consistente na story',
|
|
317
|
+
'nenhum problema real',
|
|
318
|
+
];
|
|
319
|
+
|
|
320
|
+
function normalizeForSearch(text) {
|
|
321
|
+
return String(text ?? '')
|
|
322
|
+
.normalize('NFD').replace(/[̀-ͯ]/g, '')
|
|
323
|
+
.toLowerCase()
|
|
324
|
+
.replace(/\s+/g, ' ')
|
|
325
|
+
.trim();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** O texto do finding conclui que o problema não existe? (função PURA) */
|
|
329
|
+
export function isSelfRefuting(text) {
|
|
330
|
+
const alvo = normalizeForSearch(text);
|
|
331
|
+
return SELF_REFUTING.some(frase => alvo.includes(normalizeForSearch(frase)));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** A citação aparece literalmente no documento auditado? (função PURA) */
|
|
335
|
+
export function quoteIsVerifiable(quote, documents = []) {
|
|
336
|
+
const trecho = normalizeForSearch(quote);
|
|
337
|
+
if (trecho.length < 10) return false; // curto demais para localizar
|
|
338
|
+
return documents.some(doc => normalizeForSearch(doc).includes(trecho));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Rebaixa os "grave" que não se sustentam como bloqueio (função PURA).
|
|
343
|
+
*
|
|
344
|
+
* Rebaixado ≠ descartado: o finding continua no comentário, com o motivo do
|
|
345
|
+
* rebaixamento, e só deixa de fazer o Action falhar. Quem discordar do
|
|
346
|
+
* rebaixamento tem o texto na frente para decidir.
|
|
347
|
+
*
|
|
348
|
+
* @param {Array} findings findings validados
|
|
349
|
+
* @param {string[]} documents conteúdos auditados (spec, plan, decomposition…)
|
|
350
|
+
* @returns {Array} findings com `downgraded` e `downgradeReason` quando aplicável
|
|
351
|
+
*/
|
|
352
|
+
export function downgradeUnsupportedFindings(findings = [], documents = []) {
|
|
353
|
+
return findings.map((f) => {
|
|
354
|
+
if (f.severity !== 'grave') return f;
|
|
355
|
+
if (isSelfRefuting(f.text)) {
|
|
356
|
+
return {
|
|
357
|
+
...f,
|
|
358
|
+
severity: 'menor',
|
|
359
|
+
downgraded: 'grave',
|
|
360
|
+
downgradeReason: 'o próprio texto conclui que não há contradição',
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
if (!f.quote) {
|
|
364
|
+
return {
|
|
365
|
+
...f,
|
|
366
|
+
severity: 'menor',
|
|
367
|
+
downgraded: 'grave',
|
|
368
|
+
downgradeReason: 'veio sem citação do trecho (o contrato exige uma para bloquear)',
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
if (!quoteIsVerifiable(f.quote, documents)) {
|
|
372
|
+
return {
|
|
373
|
+
...f,
|
|
374
|
+
severity: 'menor',
|
|
375
|
+
downgraded: 'grave',
|
|
376
|
+
downgradeReason: 'a citação não foi encontrada nos documentos auditados',
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
return f;
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
253
383
|
// ---------------------------------------------------------------------------
|
|
254
384
|
// Marcador de tentativa e contador
|
|
255
385
|
// ---------------------------------------------------------------------------
|
|
@@ -623,15 +753,29 @@ export function sanitizeFindingText(text) {
|
|
|
623
753
|
*/
|
|
624
754
|
export function renderCritiqueMarkdown({
|
|
625
755
|
kind = 'plan', findings = [], attempt = 1,
|
|
626
|
-
maxAttempts = DEFAULT_MAX_CRITIQUE_ATTEMPTS, model = '',
|
|
756
|
+
maxAttempts = DEFAULT_MAX_CRITIQUE_ATTEMPTS, model = '', standalone = false,
|
|
627
757
|
} = {}) {
|
|
628
758
|
const graves = findings.filter(f => f.severity === 'grave');
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
759
|
+
// Rebaixados saem da lista de menores e ganham seção própria: enterrá-los
|
|
760
|
+
// entre os menores esconderia que a crítica MUDOU a classificação do modelo,
|
|
761
|
+
// que é justamente o que o humano precisa poder contestar.
|
|
762
|
+
const rebaixados = findings.filter(f => f.downgraded);
|
|
763
|
+
const menores = findings.filter(f => f.severity === 'menor' && !f.downgraded);
|
|
764
|
+
// `standalone`: crítica AVULSA (spec-wave critique --file), fora do ciclo de
|
|
765
|
+
// tentativas. Sem marcador de propósito — é o marcador que alimenta o
|
|
766
|
+
// contador, e uma consulta sob demanda não pode consumir tentativa nem
|
|
767
|
+
// disparar o portão humano.
|
|
768
|
+
const parts = standalone
|
|
769
|
+
? [
|
|
770
|
+
`🔎 **Crítica avulsa (spec-wave)** — ${KIND_LABEL[kind] || KIND_LABEL.plan}` +
|
|
771
|
+
`${model ? ` · modelo \`${model}\`` : ''}`,
|
|
772
|
+
'_Rodada sob demanda, sobre o documento como está: não conta tentativa e não aplica labels._',
|
|
773
|
+
]
|
|
774
|
+
: [
|
|
775
|
+
critiqueMarker({ kind, attempt, verdict: graves.length > 0 ? 'grave' : 'limpa' }),
|
|
776
|
+
`🔎 **Crítica adversarial (spec-wave)** — ${KIND_LABEL[kind] || KIND_LABEL.plan} · ` +
|
|
777
|
+
`tentativa ${attempt}/${maxAttempts}${model ? ` · modelo \`${model}\`` : ''}`,
|
|
778
|
+
];
|
|
635
779
|
|
|
636
780
|
if (findings.length === 0) {
|
|
637
781
|
parts.push('✅ Nenhuma contradição encontrada.');
|
|
@@ -643,6 +787,17 @@ export function renderCritiqueMarkdown({
|
|
|
643
787
|
.join('\n');
|
|
644
788
|
if (graves.length > 0) parts.push(`### ❌ Graves\n\n${bullets(graves)}`);
|
|
645
789
|
if (menores.length > 0) parts.push(`### ⚠️ Menores\n\n${bullets(menores)}`);
|
|
790
|
+
if (rebaixados.length > 0) {
|
|
791
|
+
parts.push(
|
|
792
|
+
'### ↘️ Rebaixados (vieram como graves, não bloqueiam)\n\n' +
|
|
793
|
+
rebaixados
|
|
794
|
+
.map(f => `- ${f.anchor ? `**${f.anchor}** — ` : ''}${sanitizeFindingText(f.text)}\n` +
|
|
795
|
+
` - _Rebaixado: ${sanitizeFindingText(f.downgradeReason || 'não se sustenta como bloqueio')}._`)
|
|
796
|
+
.join('\n') +
|
|
797
|
+
'\n\n_Continuam valendo como observação. Se algum for mesmo grave, corrija o documento ' +
|
|
798
|
+
'ou peça nova crítica citando o trecho._'
|
|
799
|
+
);
|
|
800
|
+
}
|
|
646
801
|
parts.push((KIND_TRAILER[kind] || KIND_TRAILER.plan)(graves.length > 0));
|
|
647
802
|
// Findings estruturados, com a MESMA digital que o portão usa. É o que
|
|
648
803
|
// permite decidir item a item numa UI: parsear os bullets de volta seria
|
|
@@ -657,6 +812,7 @@ export function renderCritiqueMarkdown({
|
|
|
657
812
|
severity: f.severity,
|
|
658
813
|
anchor: f.anchor || null,
|
|
659
814
|
text: sanitizeFindingText(f.text),
|
|
815
|
+
...(f.downgraded ? { downgradedFrom: f.downgraded, downgradeReason: f.downgradeReason } : {}),
|
|
660
816
|
})),
|
|
661
817
|
}, null, 2),
|
|
662
818
|
'```',
|
|
@@ -693,7 +849,7 @@ export function renderCritiqueMarkdown({
|
|
|
693
849
|
export async function runCritique({
|
|
694
850
|
kind, spec, plan, techContextYaml, decomposition, bugDoc, bugReport,
|
|
695
851
|
attempt = 1, maxAttempts = DEFAULT_MAX_CRITIQUE_ATTEMPTS,
|
|
696
|
-
model, labels = [], usage, cwd, decisions = null,
|
|
852
|
+
model, labels = [], usage, cwd, decisions = null, standalone = false,
|
|
697
853
|
} = {}) {
|
|
698
854
|
const sections = [];
|
|
699
855
|
if (spec) sections.push(`## spec.md\n\n${spec}`);
|
|
@@ -738,12 +894,28 @@ export async function runCritique({
|
|
|
738
894
|
withReport: true,
|
|
739
895
|
});
|
|
740
896
|
|
|
741
|
-
|
|
897
|
+
// Coerência ANTES de tudo o que depende da severidade: um grave que não se
|
|
898
|
+
// sustenta não deve entrar no contador de tentativas nem bloquear o Action.
|
|
899
|
+
const findings = downgradeUnsupportedFindings(
|
|
900
|
+
report.value.findings,
|
|
901
|
+
[spec, plan, decomposition, bugDoc, techContextYaml].filter(Boolean),
|
|
902
|
+
);
|
|
903
|
+
const grave = findings.some(f => f.severity === 'grave');
|
|
904
|
+
const rebaixados = findings.filter(f => f.downgraded).length;
|
|
905
|
+
if (rebaixados > 0) {
|
|
906
|
+
console.log(
|
|
907
|
+
`${rebaixados} finding(s) grave(s) rebaixado(s) para menor por não se sustentarem ` +
|
|
908
|
+
'(auto-refutação no texto ou citação não verificável) — seguem no comentário, com o motivo.'
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
|
|
742
912
|
return {
|
|
743
913
|
grave,
|
|
744
914
|
findings,
|
|
745
915
|
attempt,
|
|
746
916
|
model: report.model,
|
|
747
|
-
markdown: renderCritiqueMarkdown({
|
|
917
|
+
markdown: renderCritiqueMarkdown({
|
|
918
|
+
kind, findings, attempt, maxAttempts, model: report.model, standalone,
|
|
919
|
+
}),
|
|
748
920
|
};
|
|
749
921
|
}
|
|
@@ -49,6 +49,11 @@ const H1_RE = /^#[ \t]+(.*)$/;
|
|
|
49
49
|
const MARKER_RE = /^<!--[ \t]*spec-wave:decomposition[ \t]+(.*?)-->[ \t]*$/i;
|
|
50
50
|
const USER_STORY_RE = /^\*\*User story:?\*\*:?[ \t]*(.*)$/i;
|
|
51
51
|
const DEPENDS_RE = /^\*\*Depende de:?\*\*:?[ \t]*(.*)$/i;
|
|
52
|
+
// Gravado pelo `decompose-apply` DEPOIS de criar a issue. É o que liga o
|
|
53
|
+
// rascunho ao que existe de fato no GitHub — sem isso o arquivo continua
|
|
54
|
+
// descrevendo a proposta original para sempre, e um rescopo feito nas issues
|
|
55
|
+
// deixa o documento como registro enganoso, sem que nada perceba.
|
|
56
|
+
const ISSUE_RE = /^\*\*Issue:?\*\*:?[ \t]*#?(\d+)[ \t]*$/i;
|
|
52
57
|
const EMPTY_VALUE_RE = /^(—|–|-|nenhuma|nenhum|none|n\/a)$/i;
|
|
53
58
|
const FENCE_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
|
|
54
59
|
|
|
@@ -176,6 +181,7 @@ function normalizeDependsOn(dependsOn, index) {
|
|
|
176
181
|
*/
|
|
177
182
|
export function renderDecompositionDoc({
|
|
178
183
|
title = '', issueNumber = null, kind = 'stories', preamble = '', stories = [], tasks = [],
|
|
184
|
+
appliedAt = null,
|
|
179
185
|
} = {}) {
|
|
180
186
|
if (kind !== 'stories' && kind !== 'tasks') {
|
|
181
187
|
throw new Error(`decomposition.md: kind inválido "${kind}" (use "stories" ou "tasks").`);
|
|
@@ -185,6 +191,10 @@ export function renderDecompositionDoc({
|
|
|
185
191
|
const issue = Number(issueNumber);
|
|
186
192
|
if (Number.isInteger(issue) && issue > 0) attrs.push(`issue=${issue}`);
|
|
187
193
|
attrs.push(`kind=${kind}`);
|
|
194
|
+
// `applied=<ISO>` marca que as issues JÁ existem. É o que separa "proposta
|
|
195
|
+
// ainda em revisão" de "registro do que foi criado" — e o que o doctor usa
|
|
196
|
+
// para conferir se o arquivo ainda descreve a árvore real.
|
|
197
|
+
if (appliedAt) attrs.push(`applied=${appliedAt}`);
|
|
188
198
|
|
|
189
199
|
const head = flatten(title) ? `# Decomposição — ${flatten(title)}` : '# Decomposição';
|
|
190
200
|
const blocks = [`${head}\n<!-- spec-wave:decomposition ${attrs.join(' ')} -->`];
|
|
@@ -192,8 +202,17 @@ export function renderDecompositionDoc({
|
|
|
192
202
|
const note = renderBody(preamble);
|
|
193
203
|
if (note) blocks.push(note);
|
|
194
204
|
|
|
205
|
+
// A linha `**Issue:** #N` sai como PRIMEIRO bloco depois do título, separada
|
|
206
|
+
// do corpo por linha em branco — mesma regra dos campos da Story, e é isso que
|
|
207
|
+
// impede um corpo começando com "**Issue:** #12" de ser lido como o campo.
|
|
208
|
+
const issueLine = (item) => {
|
|
209
|
+
const n = Number(item?.issue);
|
|
210
|
+
return Number.isInteger(n) && n > 0 ? `**Issue:** #${n}` : '';
|
|
211
|
+
};
|
|
195
212
|
const pushItem = (heading, item) => {
|
|
196
213
|
blocks.push(`${heading} — ${flatten(item?.title) || '(sem título)'}`);
|
|
214
|
+
const linha = issueLine(item);
|
|
215
|
+
if (linha) blocks.push(linha);
|
|
197
216
|
const body = renderBody(item?.body);
|
|
198
217
|
if (body) blocks.push(body);
|
|
199
218
|
};
|
|
@@ -207,10 +226,13 @@ export function renderDecompositionDoc({
|
|
|
207
226
|
// As DUAS linhas de campo saem SEMPRE, seguidas de linha em branco: é essa
|
|
208
227
|
// linha em branco que impede um corpo começando com "**Depende de:** …" de
|
|
209
228
|
// ser confundido com o campo.
|
|
210
|
-
|
|
229
|
+
const campos = [
|
|
211
230
|
`**User story:** ${flatten(story?.userStory) || '—'}`,
|
|
212
231
|
`**Depende de:** ${deps.length ? deps.map(d => `Story ${d + 1}`).join(', ') : '—'}`,
|
|
213
|
-
]
|
|
232
|
+
];
|
|
233
|
+
const linhaIssue = issueLine(story);
|
|
234
|
+
if (linhaIssue) campos.push(linhaIssue);
|
|
235
|
+
blocks.push(campos.join('\n'));
|
|
214
236
|
const body = renderBody(story?.body);
|
|
215
237
|
if (body) blocks.push(body);
|
|
216
238
|
(story?.tasks || []).forEach((task, j) => pushItem(`### Task ${i + 1}.${j + 1}`, task));
|
|
@@ -225,10 +247,12 @@ function parseMarker(attrs) {
|
|
|
225
247
|
const version = /(?:^|\s)v(\d+)(?:\s|$)/.exec(text);
|
|
226
248
|
const issue = /\bissue=(\d+)\b/.exec(text);
|
|
227
249
|
const kind = /\bkind=(stories|tasks)\b/i.exec(text);
|
|
250
|
+
const applied = /\bapplied=(\S+)/.exec(text);
|
|
228
251
|
return {
|
|
229
252
|
version: version ? parseInt(version[1], 10) : DOC_VERSION,
|
|
230
253
|
issueNumber: issue ? parseInt(issue[1], 10) : null,
|
|
231
254
|
kind: kind ? kind[1].toLowerCase() : null,
|
|
255
|
+
appliedAt: applied ? applied[1] : null,
|
|
232
256
|
};
|
|
233
257
|
}
|
|
234
258
|
|
|
@@ -276,6 +300,21 @@ function parseDependsValue(raw, index, anchor) {
|
|
|
276
300
|
return out.sort((a, b) => a - b);
|
|
277
301
|
}
|
|
278
302
|
|
|
303
|
+
// Tasks não têm campos de gramática além do título — só a linha `**Issue:** #N`
|
|
304
|
+
// gravada pelo apply. Mesma regra dos campos da Story: vale apenas no primeiro
|
|
305
|
+
// bloco não-vazio abaixo do título.
|
|
306
|
+
function splitTaskSection(lines) {
|
|
307
|
+
let i = 0;
|
|
308
|
+
while (i < lines.length && !lines[i].trim()) i++;
|
|
309
|
+
let issue = null;
|
|
310
|
+
const m = i < lines.length ? ISSUE_RE.exec(lines[i]) : null;
|
|
311
|
+
if (m) {
|
|
312
|
+
issue = parseInt(m[1], 10);
|
|
313
|
+
i++;
|
|
314
|
+
}
|
|
315
|
+
return { issue, body: readBody(lines.slice(i)) };
|
|
316
|
+
}
|
|
317
|
+
|
|
279
318
|
// Os campos valem SÓ no primeiro bloco não-vazio abaixo do título. É o que deixa
|
|
280
319
|
// o corpo conter uma linha "**Depende de:** #12" sem que ela vire campo.
|
|
281
320
|
function splitStorySection(lines, anchor) {
|
|
@@ -283,22 +322,28 @@ function splitStorySection(lines, anchor) {
|
|
|
283
322
|
while (i < lines.length && !lines[i].trim()) i++;
|
|
284
323
|
let userStory = null;
|
|
285
324
|
let depends = null;
|
|
325
|
+
let issue = null;
|
|
286
326
|
while (i < lines.length && lines[i].trim()) {
|
|
287
327
|
const us = USER_STORY_RE.exec(lines[i]);
|
|
288
328
|
const dep = us ? null : DEPENDS_RE.exec(lines[i]);
|
|
289
|
-
|
|
329
|
+
const iss = us || dep ? null : ISSUE_RE.exec(lines[i]);
|
|
330
|
+
if (!us && !dep && !iss) break;
|
|
290
331
|
if (us) {
|
|
291
332
|
if (userStory !== null) throw invalid(`${anchor} tem duas linhas "**User story:**"`);
|
|
292
333
|
userStory = us[1].trim();
|
|
293
|
-
} else {
|
|
334
|
+
} else if (dep) {
|
|
294
335
|
if (depends !== null) throw invalid(`${anchor} tem duas linhas "**Depende de:**"`);
|
|
295
336
|
depends = dep[1].trim();
|
|
337
|
+
} else {
|
|
338
|
+
if (issue !== null) throw invalid(`${anchor} tem duas linhas "**Issue:**"`);
|
|
339
|
+
issue = parseInt(iss[1], 10);
|
|
296
340
|
}
|
|
297
341
|
i++;
|
|
298
342
|
}
|
|
299
343
|
return {
|
|
300
344
|
userStory: !userStory || EMPTY_VALUE_RE.test(userStory) ? '' : userStory,
|
|
301
345
|
depends,
|
|
346
|
+
issue,
|
|
302
347
|
body: readBody(lines.slice(i)),
|
|
303
348
|
};
|
|
304
349
|
}
|
|
@@ -394,6 +439,8 @@ export function parseDecompositionDoc(markdown) {
|
|
|
394
439
|
issueNumber: marker.issueNumber,
|
|
395
440
|
kind,
|
|
396
441
|
title,
|
|
442
|
+
// null = a decomposição ainda não foi aplicada; ISO = quando foi.
|
|
443
|
+
appliedAt: marker.appliedAt,
|
|
397
444
|
preamble: readBody(preamble),
|
|
398
445
|
stories: [],
|
|
399
446
|
tasks: [],
|
|
@@ -409,7 +456,8 @@ export function parseDecompositionDoc(markdown) {
|
|
|
409
456
|
}
|
|
410
457
|
doc.tasks = sections.map((s, i) => {
|
|
411
458
|
const anchor = `Task ${i + 1}`;
|
|
412
|
-
|
|
459
|
+
const { issue, body } = splitTaskSection(s.lines);
|
|
460
|
+
return { anchor, title: requireTitle(s.title, anchor), issue, body };
|
|
413
461
|
});
|
|
414
462
|
if (doc.tasks.length === 0) throw invalid('não encontrei nenhuma Task ("## Task 1 — …")');
|
|
415
463
|
return doc;
|
|
@@ -418,12 +466,13 @@ export function parseDecompositionDoc(markdown) {
|
|
|
418
466
|
for (const section of sections) {
|
|
419
467
|
if (section.type === 'story') {
|
|
420
468
|
const anchor = `Story ${doc.stories.length + 1}`;
|
|
421
|
-
const { userStory, depends, body } = splitStorySection(section.lines, anchor);
|
|
469
|
+
const { userStory, depends, issue, body } = splitStorySection(section.lines, anchor);
|
|
422
470
|
doc.stories.push({
|
|
423
471
|
anchor,
|
|
424
472
|
title: requireTitle(section.title, anchor),
|
|
425
473
|
userStory,
|
|
426
474
|
body,
|
|
475
|
+
issue,
|
|
427
476
|
dependsOn: parseDependsValue(depends, doc.stories.length, anchor),
|
|
428
477
|
tasks: [],
|
|
429
478
|
});
|
|
@@ -443,7 +492,10 @@ export function parseDecompositionDoc(markdown) {
|
|
|
443
492
|
}
|
|
444
493
|
const story = doc.stories[doc.stories.length - 1];
|
|
445
494
|
const anchor = `Task ${doc.stories.length}.${story.tasks.length + 1}`;
|
|
446
|
-
|
|
495
|
+
const { issue: taskIssue, body: taskBody } = splitTaskSection(section.lines);
|
|
496
|
+
story.tasks.push({
|
|
497
|
+
anchor, title: requireTitle(section.title, anchor), issue: taskIssue, body: taskBody,
|
|
498
|
+
});
|
|
447
499
|
}
|
|
448
500
|
|
|
449
501
|
if (doc.stories.length === 0) throw invalid('não encontrei nenhuma Story ("## Story 1 — …")');
|
package/src/lib/flow-run.mjs
CHANGED
|
@@ -22,10 +22,12 @@
|
|
|
22
22
|
// naquele repositório. Localmente a sua identidade é preservada.
|
|
23
23
|
// 2. FALHA DE PUSH. No Action, não conseguir publicar é falha do job. Local, o
|
|
24
24
|
// arquivo já está gerado e commitado — perder isso porque o remoto andou
|
|
25
|
-
// seria pior que avisar e deixar você resolver o push.
|
|
25
|
+
// seria pior que avisar e deixar você resolver o push. Antes de chegar
|
|
26
|
+
// nessa bifurcação, os dois modos repetem pull+push enquanto a rejeição for
|
|
27
|
+
// disputa com outro run — ver §publicação concorrente.
|
|
26
28
|
|
|
27
29
|
import { execSync } from 'node:child_process';
|
|
28
|
-
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
30
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
29
31
|
import path from 'node:path';
|
|
30
32
|
import { resolveRepoContext } from './project-root.mjs';
|
|
31
33
|
import { CONFIG_FILE } from '../config.mjs';
|
|
@@ -71,6 +73,129 @@ export function resolveFlowContext({ cwd = process.cwd(), command = 'este comand
|
|
|
71
73
|
return { owner, repo, root, config, mode };
|
|
72
74
|
}
|
|
73
75
|
|
|
76
|
+
// --- publicação concorrente -------------------------------------------------
|
|
77
|
+
//
|
|
78
|
+
// `pull --rebase` seguido de `push` NÃO é atômico. Entre os dois cabe o push de
|
|
79
|
+
// outro run: dois `generate-spec` de issues DIFERENTES disparados juntos rodam
|
|
80
|
+
// em paralelo (a `concurrency` dos workflows é por issue, e é por issue que ela
|
|
81
|
+
// tem que ser — serializar o repositório inteiro mataria a vazão), terminam
|
|
82
|
+
// juntos e disputam a ponta da branch. O perdedor levava non-fast-forward,
|
|
83
|
+
// falhava o job e PERDIA o documento recém-gerado — com a chamada de IA já
|
|
84
|
+
// paga. Repetir pull+push resolve: o rebase reaplica o commit sobre a ponta
|
|
85
|
+
// nova e o segundo push passa.
|
|
86
|
+
|
|
87
|
+
export const PUSH_ATTEMPTS = 5;
|
|
88
|
+
const PUSH_BASE_MS = 500;
|
|
89
|
+
|
|
90
|
+
// Rejeição por CORRIDA: o remoto andou, e reaplicar por cima resolve.
|
|
91
|
+
const RACE_PATTERNS = /non-fast-forward|fetch first|Updates were rejected|cannot lock ref|failed to lock/i;
|
|
92
|
+
|
|
93
|
+
// Recusa DELIBERADA do remoto. Compartilha a linha genérica "failed to push
|
|
94
|
+
// some refs" com a corrida, então precisa ser testada ANTES: repetir aqui só
|
|
95
|
+
// atrasa a mensagem que o usuário precisa ler. Mesma filosofia de
|
|
96
|
+
// `isTransientProviderError` (lib/claude.mjs).
|
|
97
|
+
const REFUSED_PATTERNS = /GH006|protected branch|pre-receive hook declined|permission to .* denied|403 Forbidden|Authentication failed|could not read Username|Repository not found/i;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A saída do git indica disputa pela ponta da branch? (função PURA)
|
|
101
|
+
*
|
|
102
|
+
* @param {string} output stdout+stderr do comando que falhou
|
|
103
|
+
* @returns {boolean}
|
|
104
|
+
*/
|
|
105
|
+
export function isRacePushError(output) {
|
|
106
|
+
const text = String(output || '');
|
|
107
|
+
if (!text) return false;
|
|
108
|
+
if (REFUSED_PATTERNS.test(text)) return false;
|
|
109
|
+
return RACE_PATTERNS.test(text);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Espera antes da próxima tentativa (função PURA).
|
|
114
|
+
*
|
|
115
|
+
* Exponencial COM jitter: sem o jitter, dois runs que colidiram uma vez voltam
|
|
116
|
+
* a colidir no mesmo instante — o backoff determinístico os mantém em fase.
|
|
117
|
+
*
|
|
118
|
+
* @param {number} attempt tentativa que acabou de falhar (1-based)
|
|
119
|
+
* @param {object} [opts]
|
|
120
|
+
* @param {number} [opts.baseMs]
|
|
121
|
+
* @param {() => number} [opts.random] injetável para o teste
|
|
122
|
+
* @returns {number} milissegundos
|
|
123
|
+
*/
|
|
124
|
+
export function pushBackoffMs(attempt, { baseMs = PUSH_BASE_MS, random = Math.random } = {}) {
|
|
125
|
+
const teto = baseMs * 2 ** (attempt - 1); // 500ms, 1s, 2s, 4s…
|
|
126
|
+
return Math.round(teto * (1 + random())); // [teto, 2*teto)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Sleep BLOQUEANTE de propósito: `commitGenerated` é síncrona de ponta a ponta
|
|
130
|
+
// (execSync), e um único `await` no meio obrigaria os três comandos chamadores
|
|
131
|
+
// e a suíte inteira a virarem async para nada — nada mais roda nesta thread
|
|
132
|
+
// enquanto o git trabalha.
|
|
133
|
+
function sleepSync(ms) {
|
|
134
|
+
if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Roda um comando git capturando a saída, e a devolve anexada ao erro.
|
|
139
|
+
*
|
|
140
|
+
* Com `stdio: 'inherit'` o texto do git vai para o log mas NÃO chega ao
|
|
141
|
+
* processo: `err.message` é só "Command failed: git push", e não dá para
|
|
142
|
+
* distinguir corrida de branch protegida. Capturando, o log continua igual
|
|
143
|
+
* (reemitimos) e a classificação passa a ser possível.
|
|
144
|
+
*/
|
|
145
|
+
function gitCaptured(cmd) {
|
|
146
|
+
try {
|
|
147
|
+
const out = execSync(cmd, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
148
|
+
if (out?.length) process.stdout.write(out);
|
|
149
|
+
return;
|
|
150
|
+
} catch (err) {
|
|
151
|
+
const detail = [err.stdout, err.stderr].map(b => b?.toString() || '').join('').trim();
|
|
152
|
+
if (detail) process.stderr.write(`${detail}\n`);
|
|
153
|
+
const resumo = detail.split('\n').find(l => l.trim()) || err.message;
|
|
154
|
+
const erro = new Error(`${cmd} falhou: ${resumo}`);
|
|
155
|
+
erro.gitOutput = detail;
|
|
156
|
+
throw erro;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Rebase interrompido (conflito, ou pull abortado no meio) deixa o repositório
|
|
161
|
+
// EM rebase. No runner descartável tanto faz; no clone do usuário, sair assim
|
|
162
|
+
// é deixar um estado que ele não pediu e talvez nem perceba.
|
|
163
|
+
function abortRebaseIfAny() {
|
|
164
|
+
try {
|
|
165
|
+
const dir = execSync('git rev-parse --git-path rebase-merge', { stdio: 'pipe' }).toString().trim();
|
|
166
|
+
const dirApply = execSync('git rev-parse --git-path rebase-apply', { stdio: 'pipe' }).toString().trim();
|
|
167
|
+
if (!existsSync(dir) && !existsSync(dirApply)) return;
|
|
168
|
+
execSync('git rebase --abort', { stdio: 'pipe' });
|
|
169
|
+
} catch {
|
|
170
|
+
// Melhor esforço: se nem abortar dá, o erro original é o que importa.
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* pull --rebase + push, repetindo enquanto a falha for disputa pela branch.
|
|
176
|
+
*
|
|
177
|
+
* @returns {{attempts: number}}
|
|
178
|
+
* @throws o erro do git quando não é corrida, ou quando as tentativas acabam
|
|
179
|
+
*/
|
|
180
|
+
function publishWithRetry({ attempts, baseMs }) {
|
|
181
|
+
for (let attempt = 1; ; attempt++) {
|
|
182
|
+
try {
|
|
183
|
+
gitCaptured('git pull --rebase');
|
|
184
|
+
gitCaptured('git push');
|
|
185
|
+
return { attempts: attempt };
|
|
186
|
+
} catch (err) {
|
|
187
|
+
abortRebaseIfAny();
|
|
188
|
+
if (attempt >= attempts || !isRacePushError(err.gitOutput || err.message)) throw err;
|
|
189
|
+
const espera = pushBackoffMs(attempt, { baseMs });
|
|
190
|
+
console.warn(
|
|
191
|
+
`Publicação disputada por outro run (tentativa ${attempt}/${attempts}) — ` +
|
|
192
|
+
`repetindo em ${(espera / 1000).toFixed(1)}s.`
|
|
193
|
+
);
|
|
194
|
+
sleepSync(espera);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
74
199
|
/**
|
|
75
200
|
* Grava um arquivo gerado e o publica: commit + pull --rebase + push.
|
|
76
201
|
*
|
|
@@ -87,9 +212,11 @@ export function resolveFlowContext({ cwd = process.cwd(), command = 'este comand
|
|
|
87
212
|
* @param {string} params.content
|
|
88
213
|
* @param {string} params.message mensagem de commit
|
|
89
214
|
* @param {'actions'|'local'} params.mode
|
|
215
|
+
* @param {{attempts?: number, baseMs?: number}} [params.retry] ajuste do laço de
|
|
216
|
+
* publicação — existe para o teste não dormir o backoff real
|
|
90
217
|
* @returns {{committed: boolean, pushed: boolean, warning: string|null}}
|
|
91
218
|
*/
|
|
92
|
-
export function commitGenerated({ filePath, content, message, mode }) {
|
|
219
|
+
export function commitGenerated({ filePath, content, message, mode, retry = {} }) {
|
|
93
220
|
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
94
221
|
writeFileSync(filePath, content, 'utf-8');
|
|
95
222
|
|
|
@@ -120,8 +247,10 @@ export function commitGenerated({ filePath, content, message, mode }) {
|
|
|
120
247
|
git(`git commit -m "${message}" -- "${filePath}"`);
|
|
121
248
|
|
|
122
249
|
try {
|
|
123
|
-
|
|
124
|
-
|
|
250
|
+
publishWithRetry({
|
|
251
|
+
attempts: retry.attempts ?? PUSH_ATTEMPTS,
|
|
252
|
+
baseMs: retry.baseMs ?? PUSH_BASE_MS,
|
|
253
|
+
});
|
|
125
254
|
return { committed: true, pushed: true, warning: null };
|
|
126
255
|
} catch (err) {
|
|
127
256
|
if (mode === 'actions') throw err;
|
|
@@ -187,12 +187,19 @@ const REQUIRES_TOOLS_RE = /<!--\s*requires-tools\s*-->[\s\S]*?<!--\s*\/requires-
|
|
|
187
187
|
/**
|
|
188
188
|
* Texto de sistema para um runtime SEM ferramentas (função PURA).
|
|
189
189
|
*
|
|
190
|
-
* `
|
|
191
|
-
*
|
|
192
|
-
*
|
|
190
|
+
* `generateStructured` faz UMA chamada com `tool_choice` forçado: não há loop de
|
|
191
|
+
* tool use, então o modelo não tem `Read`/`Glob`/`Grep` por mais que o
|
|
192
|
+
* frontmatter os declare. Um prompt que afirme possuir ferramentas nesse
|
|
193
193
|
* runtime mente para o modelo — e o resultado típico é uma seção inventada
|
|
194
194
|
* "conforme verifiquei no repositório".
|
|
195
195
|
*
|
|
196
|
+
* ATENÇÃO ao escolher entre esta e `systemPromptWithTools`: `generateDocument`
|
|
197
|
+
* JÁ NÃO é um runtime sem ferramentas — ele passa `['Read','Glob','Grep']` ao
|
|
198
|
+
* motor (ver `lib/claude.mjs`). Usar este recorte lá inverte o problema que ele
|
|
199
|
+
* resolve: o modelo recebe as ferramentas com a orientação de uso arrancada, e
|
|
200
|
+
* um modelo que não fecha o loop sozinho perambula até o teto de turnos sem
|
|
201
|
+
* escrever o documento. Foi o que travou um Action por 55 minutos.
|
|
202
|
+
*
|
|
196
203
|
* Por isso o corpo marca a orientação dependente de ferramentas entre
|
|
197
204
|
* `<!-- requires-tools -->` e `<!-- /requires-tools -->`, e este recorte a
|
|
198
205
|
* remove. O mesmo arquivo serve os dois runtimes sem manter duas versões.
|
|
@@ -210,6 +217,26 @@ export function toolFreeSystemPrompt(prompt, appendInstruction = '') {
|
|
|
210
217
|
return [stripped, (appendInstruction || '').trim()].filter(Boolean).join('\n\n');
|
|
211
218
|
}
|
|
212
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Texto de sistema para um runtime COM ferramentas de leitura (função PURA).
|
|
222
|
+
*
|
|
223
|
+
* Par de `toolFreeSystemPrompt`: preserva o bloco `<!-- requires-tools -->`, que
|
|
224
|
+
* é onde o prompt ensina o modelo a explorar o repositório E a parar de
|
|
225
|
+
* explorar. É o que `generateDocument` precisa, porque ali o modelo de fato
|
|
226
|
+
* recebe `Read`/`Glob`/`Grep`.
|
|
227
|
+
*
|
|
228
|
+
* O `appendInstruction` vem por ÚLTIMO pela mesma razão que na irmã: um prompt
|
|
229
|
+
* sobrescrito pelo projeto não pode anular uma regra que a CLI garante.
|
|
230
|
+
*
|
|
231
|
+
* @param {ReturnType<typeof normalizePrompt>} prompt
|
|
232
|
+
* @param {string} [appendInstruction]
|
|
233
|
+
* @returns {string}
|
|
234
|
+
*/
|
|
235
|
+
export function systemPromptWithTools(prompt, appendInstruction = '') {
|
|
236
|
+
const body = prompt.prompt.replace(/\n{3,}/g, '\n\n').trim();
|
|
237
|
+
return [body, (appendInstruction || '').trim()].filter(Boolean).join('\n\n');
|
|
238
|
+
}
|
|
239
|
+
|
|
213
240
|
/**
|
|
214
241
|
* Converte um prompt em `AgentRunOptions` do `@spec-wave/agent` (função PURA).
|
|
215
242
|
*
|