@spec-wave/cli 0.16.3 → 0.17.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/package.json +1 -1
- package/src/commands/decompose.mjs +19 -4
- package/src/commands/generate-bug.mjs +6 -1
- package/src/commands/generate-plan.mjs +25 -4
- package/src/commands/generate-spec.mjs +6 -1
- package/src/commands/implement.mjs +121 -31
- package/src/config.mjs +5 -0
- package/src/lib/critique.mjs +273 -1
- package/src/lib/implement-board.mjs +10 -0
- package/src/lib/unwrap-doc.mjs +64 -0
- package/src/plugin/.claude-plugin/plugin.json +1 -1
package/package.json
CHANGED
|
@@ -26,7 +26,10 @@ import {
|
|
|
26
26
|
import { addSubIssue, listSubIssues } from '../api/github-graphql.mjs';
|
|
27
27
|
import { loadProjectConfig, resolveField, advanceToStage } from '../lib/board.mjs';
|
|
28
28
|
import { generateDocument } from '../lib/claude.mjs';
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
runCritique, resolveCritiqueAttempt, renderNeedsHumanComment,
|
|
31
|
+
parseCritiqueDecisions, applyCritiqueDecisions, renderRiskAcceptedComment,
|
|
32
|
+
} from '../lib/critique.mjs';
|
|
30
33
|
import { recordUsage } from '../lib/usage-report.mjs';
|
|
31
34
|
import { formatDependencyLine } from '../lib/dependencies.mjs';
|
|
32
35
|
import { lintLanguage } from '../lib/output-lint.mjs';
|
|
@@ -40,7 +43,7 @@ import {
|
|
|
40
43
|
} from '../lib/decomposition-doc.mjs';
|
|
41
44
|
import {
|
|
42
45
|
DECOMPOSE_TARGETS, LABEL_DECOMPOSE, LABEL_DECOMPOSE_APPLY, LABEL_DECOMPOSE_READY,
|
|
43
|
-
LABEL_DECOMPOSED, LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN, TARGET_LANGUAGE,
|
|
46
|
+
LABEL_DECOMPOSED, LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN, LABEL_RISK_ACCEPTED, TARGET_LANGUAGE,
|
|
44
47
|
STAGE_READY, PROGRESS_TODO, DEFAULT_MAX_CRITIQUE_ATTEMPTS, labelNames,
|
|
45
48
|
} from '../config.mjs';
|
|
46
49
|
|
|
@@ -294,6 +297,8 @@ async function draftDecomposition(ctx) {
|
|
|
294
297
|
const { attempt, blocked } = resolveCritiqueAttempt({
|
|
295
298
|
comments: ctx.comments, labels, kind: 'stories', maxAttempts: ctx.maxCritiqueAttempts,
|
|
296
299
|
});
|
|
300
|
+
// Decisões do TL sobre findings anteriores desta decomposição.
|
|
301
|
+
const decisions = parseCritiqueDecisions(ctx.comments, 'stories');
|
|
297
302
|
if (blocked) {
|
|
298
303
|
console.log(`Teto de ${ctx.maxCritiqueAttempts} tentativas de crítica atingido — exigindo revisão humana.`);
|
|
299
304
|
await commentOnIssue(token, owner, repo, number, renderNeedsHumanComment({
|
|
@@ -318,6 +323,7 @@ async function draftDecomposition(ctx) {
|
|
|
318
323
|
try {
|
|
319
324
|
critique = await runCritique({
|
|
320
325
|
kind: 'stories',
|
|
326
|
+
decisions,
|
|
321
327
|
spec: specContent,
|
|
322
328
|
plan: planContent,
|
|
323
329
|
decomposition: markdown,
|
|
@@ -345,8 +351,17 @@ async function draftDecomposition(ctx) {
|
|
|
345
351
|
await commentOnIssue(token, owner, repo, number, critique.markdown)
|
|
346
352
|
.catch(err => console.warn(`Falha ao comentar a crítica: ${err.message}`));
|
|
347
353
|
|
|
348
|
-
|
|
349
|
-
|
|
354
|
+
// Graves SEM decisão do TL. É este o portão que travava a #18: com spec e
|
|
355
|
+
// plan em contradição, nenhum rascunho passava — agora o TL pode afirmar que
|
|
356
|
+
// um documento está certo, ou aceitar o risco, item a item.
|
|
357
|
+
const { blocking, accepted } = applyCritiqueDecisions(critique.findings, decisions, 'stories');
|
|
358
|
+
if (accepted.length > 0) {
|
|
359
|
+
await addLabel(token, owner, repo, number, LABEL_RISK_ACCEPTED).catch(() => {});
|
|
360
|
+
await commentOnIssue(token, owner, repo, number,
|
|
361
|
+
renderRiskAcceptedComment({ kind: 'stories', accepted })).catch(() => {});
|
|
362
|
+
}
|
|
363
|
+
if (blocking.length > 0) {
|
|
364
|
+
console.log(`Crítica adversarial apontou ${blocking.length} finding(s) GRAVE(s) sem decisão — nenhum item foi criado.`);
|
|
350
365
|
await addLabel(token, owner, repo, number, LABEL_CRITIQUE_FAILED);
|
|
351
366
|
await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE_READY).catch(() => {});
|
|
352
367
|
await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE);
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
getIssue, removeLabel, addLabel, commentOnIssue, listIssueComments,
|
|
16
16
|
} from '../api/github-rest.mjs';
|
|
17
17
|
import { generateDocument } from '../lib/claude.mjs';
|
|
18
|
+
import { unwrapGeneratedDoc } from '../lib/unwrap-doc.mjs';
|
|
18
19
|
import { recordUsage } from '../lib/usage-report.mjs';
|
|
19
20
|
import { loadConfig } from '../lib/project-root.mjs';
|
|
20
21
|
import { loadPrompt, toolFreeSystemPrompt } from '../lib/prompt-loader.mjs';
|
|
@@ -103,7 +104,7 @@ export async function generateBug({ issueNumber }) {
|
|
|
103
104
|
try {
|
|
104
105
|
console.log(`Gerando bug.md para: ${issue.title}`);
|
|
105
106
|
const systemPrompt = toolFreeSystemPrompt(loadPrompt('bug', { cwd: root }));
|
|
106
|
-
const { content } = await generateDocument(systemPrompt, userContent, {
|
|
107
|
+
const { content: bruto } = await generateDocument(systemPrompt, userContent, {
|
|
107
108
|
action: 'bug',
|
|
108
109
|
labels: issueLabels,
|
|
109
110
|
lint: { lang: TARGET_LANGUAGE },
|
|
@@ -111,6 +112,10 @@ export async function generateBug({ issueNumber }) {
|
|
|
111
112
|
usage: usageEntries,
|
|
112
113
|
});
|
|
113
114
|
|
|
115
|
+
// O modelo às vezes entrega o documento embrulhado em prosa e cerca —
|
|
116
|
+
// ver lib/unwrap-doc.mjs. O que vai para o repositório é o documento.
|
|
117
|
+
const content = unwrapGeneratedDoc(bruto);
|
|
118
|
+
|
|
114
119
|
mkdirSync(dirAbs, { recursive: true });
|
|
115
120
|
writeFileSync(fileAbs, content, 'utf-8');
|
|
116
121
|
|
|
@@ -7,11 +7,14 @@ import {
|
|
|
7
7
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
8
8
|
import {
|
|
9
9
|
allowsSpecPlan, SPEC_PLAN_EXCLUDED_TYPES, TARGET_LANGUAGE,
|
|
10
|
-
LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN,
|
|
10
|
+
LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN, LABEL_RISK_ACCEPTED,
|
|
11
|
+
DEFAULT_MAX_CRITIQUE_ATTEMPTS, labelNames,
|
|
11
12
|
} from '../config.mjs';
|
|
12
13
|
import { generateDocument } from '../lib/claude.mjs';
|
|
14
|
+
import { unwrapGeneratedDoc } from '../lib/unwrap-doc.mjs';
|
|
13
15
|
import {
|
|
14
16
|
runCritique, resolveCritiqueAttempt, renderNeedsHumanComment,
|
|
17
|
+
parseCritiqueDecisions, applyCritiqueDecisions, renderRiskAcceptedComment,
|
|
15
18
|
} from '../lib/critique.mjs';
|
|
16
19
|
import { recordUsage } from '../lib/usage-report.mjs';
|
|
17
20
|
import { slugify } from '../lib/slugify.mjs';
|
|
@@ -65,6 +68,9 @@ async function critiquePlan({
|
|
|
65
68
|
return [];
|
|
66
69
|
});
|
|
67
70
|
const { attempt, blocked } = resolveCritiqueAttempt({ comments, labels, kind: 'plan', maxAttempts });
|
|
71
|
+
// Decisões que o Tech Leader já tomou sobre findings anteriores. Entram no
|
|
72
|
+
// prompt (para a crítica reconsiderar) e no portão (grave decidido não bloqueia).
|
|
73
|
+
const decisions = parseCritiqueDecisions(comments, 'plan');
|
|
68
74
|
|
|
69
75
|
if (blocked) {
|
|
70
76
|
console.log(`Teto de ${maxAttempts} tentativas de crítica atingido — exigindo revisão humana.`);
|
|
@@ -81,6 +87,7 @@ async function critiquePlan({
|
|
|
81
87
|
try {
|
|
82
88
|
critique = await runCritique({
|
|
83
89
|
kind: 'plan', spec, plan, techContextYaml, attempt, maxAttempts, model, labels, usage,
|
|
90
|
+
decisions,
|
|
84
91
|
});
|
|
85
92
|
} catch (err) {
|
|
86
93
|
console.warn(`Crítica adversarial não concluiu: ${err.message}`);
|
|
@@ -97,9 +104,19 @@ async function critiquePlan({
|
|
|
97
104
|
await commentOnIssue(token, owner, repo, issueNumber, critique.markdown)
|
|
98
105
|
.catch(err => console.warn(`Falha ao comentar a crítica: ${err.message}`));
|
|
99
106
|
|
|
100
|
-
|
|
107
|
+
// O portão olha os graves SEM decisão do TL. Um grave que ele marcou como
|
|
108
|
+
// `ignorar` (risco aceito) ou `plano-correto` não trava o fluxo — foi decisão
|
|
109
|
+
// humana explícita, registrada e auditável na issue.
|
|
110
|
+
const { blocking, accepted } = applyCritiqueDecisions(critique.findings, decisions, 'plan');
|
|
111
|
+
if (accepted.length > 0) {
|
|
112
|
+
await addLabel(token, owner, repo, issueNumber, LABEL_RISK_ACCEPTED).catch(() => {});
|
|
113
|
+
await commentOnIssue(token, owner, repo, issueNumber,
|
|
114
|
+
renderRiskAcceptedComment({ kind: 'plan', accepted })).catch(() => {});
|
|
115
|
+
console.log(`${accepted.length} finding(s) aceito(s) como risco pelo Tech Leader.`);
|
|
116
|
+
}
|
|
117
|
+
if (blocking.length > 0) {
|
|
101
118
|
await addLabel(token, owner, repo, issueNumber, LABEL_CRITIQUE_FAILED);
|
|
102
|
-
console.log(`Crítica adversarial apontou
|
|
119
|
+
console.log(`Crítica adversarial apontou ${blocking.length} finding(s) GRAVE(s) sem decisão — label ${LABEL_CRITIQUE_FAILED} aplicada.`);
|
|
103
120
|
return;
|
|
104
121
|
}
|
|
105
122
|
// Crítica limpa remove o bloqueio anterior — é isso que zera o contador de
|
|
@@ -165,7 +182,7 @@ export async function generatePlan({ issueNumber }) {
|
|
|
165
182
|
try {
|
|
166
183
|
console.log(`Gerando plan.md para: ${issue.title}`);
|
|
167
184
|
const systemPrompt = toolFreeSystemPrompt(loadPrompt('plan', { cwd: root }));
|
|
168
|
-
const { content, lintFindings } = await generateDocument(systemPrompt, userContent, {
|
|
185
|
+
const { content: bruto, lintFindings } = await generateDocument(systemPrompt, userContent, {
|
|
169
186
|
action: 'plan',
|
|
170
187
|
labels: issueLabels,
|
|
171
188
|
lint: { lang: TARGET_LANGUAGE },
|
|
@@ -173,6 +190,10 @@ export async function generatePlan({ issueNumber }) {
|
|
|
173
190
|
usage: usageEntries,
|
|
174
191
|
});
|
|
175
192
|
|
|
193
|
+
// O modelo às vezes entrega o documento embrulhado em prosa e cerca —
|
|
194
|
+
// ver lib/unwrap-doc.mjs. O que vai para o repositório é o documento.
|
|
195
|
+
const content = unwrapGeneratedDoc(bruto);
|
|
196
|
+
|
|
176
197
|
const published = commitGenerated({
|
|
177
198
|
filePath,
|
|
178
199
|
content,
|
|
@@ -2,6 +2,7 @@ import path from 'node:path';
|
|
|
2
2
|
import { resolveToken } from '../api/auth.mjs';
|
|
3
3
|
import { getIssue, removeLabel, commentOnIssue } from '../api/github-rest.mjs';
|
|
4
4
|
import { generateDocument } from '../lib/claude.mjs';
|
|
5
|
+
import { unwrapGeneratedDoc } from '../lib/unwrap-doc.mjs';
|
|
5
6
|
import { recordUsage } from '../lib/usage-report.mjs';
|
|
6
7
|
import { slugify } from '../lib/slugify.mjs';
|
|
7
8
|
import { resolveFromRoot } from '../lib/project-root.mjs';
|
|
@@ -79,7 +80,7 @@ export async function generateSpec({ issueNumber }) {
|
|
|
79
80
|
try {
|
|
80
81
|
console.log(`Gerando spec.md para: ${issue.title}`);
|
|
81
82
|
const systemPrompt = toolFreeSystemPrompt(loadPrompt('spec', { cwd: root }));
|
|
82
|
-
const { content, lintFindings } = await generateDocument(systemPrompt, userContent, {
|
|
83
|
+
const { content: bruto, lintFindings } = await generateDocument(systemPrompt, userContent, {
|
|
83
84
|
action: 'spec',
|
|
84
85
|
labels: issueLabels,
|
|
85
86
|
lint: { lang: TARGET_LANGUAGE },
|
|
@@ -87,6 +88,10 @@ export async function generateSpec({ issueNumber }) {
|
|
|
87
88
|
usage: usageEntries,
|
|
88
89
|
});
|
|
89
90
|
|
|
91
|
+
// O modelo às vezes entrega o documento embrulhado em prosa e cerca —
|
|
92
|
+
// ver lib/unwrap-doc.mjs. O que vai para o repositório é o documento.
|
|
93
|
+
const content = unwrapGeneratedDoc(bruto);
|
|
94
|
+
|
|
90
95
|
const published = commitGenerated({
|
|
91
96
|
filePath,
|
|
92
97
|
content,
|
|
@@ -227,6 +227,75 @@ function buildContext({
|
|
|
227
227
|
return lines.join('\n');
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Etapa de cada item no board, por número. Best-effort: qualquer falha vira
|
|
232
|
+
* `null` para aquele item (tratado como "não implementado" por quem consome).
|
|
233
|
+
*
|
|
234
|
+
* A leitura passa por `addProjectItem`, que é MUTAÇÃO — adiciona o item ao
|
|
235
|
+
* Project se ainda não estiver lá. Por isso a consulta inteira é pulada em
|
|
236
|
+
* dry-run: um dry-run não pode ter efeito remoto.
|
|
237
|
+
*
|
|
238
|
+
* @param {string} token
|
|
239
|
+
* @param {Array<{number:number, nodeId:string}>} items
|
|
240
|
+
* @param {{dryRun?:boolean, warnOnSkip?:boolean}} [opts]
|
|
241
|
+
* @returns {Promise<Map<number, string|null>>}
|
|
242
|
+
*/
|
|
243
|
+
async function fetchStagesOf(token, items, { dryRun = false, warnOnSkip = false } = {}) {
|
|
244
|
+
const stageOf = new Map();
|
|
245
|
+
if (items.length === 0) return stageOf;
|
|
246
|
+
const { project, error: projectError } = loadProjectConfig();
|
|
247
|
+
if (dryRun) {
|
|
248
|
+
if (warnOnSkip) {
|
|
249
|
+
p.log.warn(
|
|
250
|
+
'Dry-run: Etapas do board não consultadas (a consulta adicionaria itens ao Project); ' +
|
|
251
|
+
'nenhuma Story será considerada implementada.'
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
return stageOf;
|
|
255
|
+
}
|
|
256
|
+
if (projectError) {
|
|
257
|
+
if (warnOnSkip) {
|
|
258
|
+
p.log.warn(`${projectError} — Etapas do board não consultadas; nenhuma Story será considerada implementada.`);
|
|
259
|
+
}
|
|
260
|
+
return stageOf;
|
|
261
|
+
}
|
|
262
|
+
const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
|
|
263
|
+
if (!etapaField?.id) return stageOf;
|
|
264
|
+
await Promise.all(items.map(async (s) => {
|
|
265
|
+
try {
|
|
266
|
+
const itemId = await addProjectItem(token, project.id, s.nodeId);
|
|
267
|
+
stageOf.set(s.number, await getItemSingleSelectValue(token, itemId, etapaField.id));
|
|
268
|
+
} catch {
|
|
269
|
+
stageOf.set(s.number, null);
|
|
270
|
+
}
|
|
271
|
+
}));
|
|
272
|
+
return stageOf;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* A Feature pode avançar para Code Review? Só quando TODAS as Stories irmãs já
|
|
277
|
+
* estão em Code Review ou além. Pura — recebe as etapas já consultadas.
|
|
278
|
+
*
|
|
279
|
+
* Etapa desconhecida (`null`/ausente) conta como PENDENTE: na dúvida a Feature
|
|
280
|
+
* não avança. Um falso avanço mente sobre o estado do trabalho; um falso
|
|
281
|
+
* "não avança" só adia, e a etapa nunca retrocede depois.
|
|
282
|
+
*
|
|
283
|
+
* @param {Array<{number:number}>} siblings Stories irmãs (sem a atual)
|
|
284
|
+
* @param {Map<number, string|null>} stageOf
|
|
285
|
+
* @param {{reviewStage?:string, stageOrder?:string[]}} [opts]
|
|
286
|
+
*/
|
|
287
|
+
export function featureCanAdvance(siblings, stageOf, {
|
|
288
|
+
reviewStage = STAGE_CODE_REVIEW, stageOrder = STAGE_ORDER,
|
|
289
|
+
} = {}) {
|
|
290
|
+
const reviewIdx = stageOrder.indexOf(reviewStage);
|
|
291
|
+
if (reviewIdx < 0) return false;
|
|
292
|
+
return siblings.every((s) => {
|
|
293
|
+
const stage = stageOf.get(s.number) ?? null;
|
|
294
|
+
const idx = stage ? stageOrder.indexOf(stage) : -1;
|
|
295
|
+
return idx >= reviewIdx;
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
230
299
|
/**
|
|
231
300
|
* Planeja a implementação de uma Feature: separa as Stories já implementadas
|
|
232
301
|
* (Etapa >= reviewStage na ordem canônica) das pendentes e ordena as pendentes
|
|
@@ -496,31 +565,7 @@ async function implementFeature({ token, owner, repo, config, feature, featureDi
|
|
|
496
565
|
}));
|
|
497
566
|
|
|
498
567
|
// F3. Etapa de cada Story no board (best-effort — sem board, nada é pulado).
|
|
499
|
-
const {
|
|
500
|
-
const stageOf = new Map();
|
|
501
|
-
if (dryRun) {
|
|
502
|
-
// A leitura da Etapa passa por addProjectItem, que é MUTAÇÃO (adiciona a
|
|
503
|
-
// Story ao Project se ainda não estiver lá). Num dry-run isso é proibido, então
|
|
504
|
-
// a consulta é pulada inteira — o planejamento segue com stage null.
|
|
505
|
-
p.log.warn(
|
|
506
|
-
'Dry-run: Etapas do board não consultadas (a consulta adicionaria itens ao Project); ' +
|
|
507
|
-
'nenhuma Story será considerada implementada.'
|
|
508
|
-
);
|
|
509
|
-
} else if (projectError) {
|
|
510
|
-
p.log.warn(`${projectError} — Etapas do board não consultadas; nenhuma Story será considerada implementada.`);
|
|
511
|
-
} else {
|
|
512
|
-
const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
|
|
513
|
-
if (etapaField?.id) {
|
|
514
|
-
await Promise.all(enriched.map(async (s) => {
|
|
515
|
-
try {
|
|
516
|
-
const itemId = await addProjectItem(token, project.id, s.nodeId);
|
|
517
|
-
stageOf.set(s.number, await getItemSingleSelectValue(token, itemId, etapaField.id));
|
|
518
|
-
} catch {
|
|
519
|
-
stageOf.set(s.number, null);
|
|
520
|
-
}
|
|
521
|
-
}));
|
|
522
|
-
}
|
|
523
|
-
}
|
|
568
|
+
const stageOf = await fetchStagesOf(token, enriched, { dryRun, warnOnSkip: true });
|
|
524
569
|
|
|
525
570
|
// F4. Planejamento: pendentes em ordem topológica, puladas, ciclos.
|
|
526
571
|
const { pending, skipped, cycle } = planFeatureImplementation(
|
|
@@ -542,6 +587,14 @@ async function implementFeature({ token, owner, repo, config, feature, featureDi
|
|
|
542
587
|
);
|
|
543
588
|
}
|
|
544
589
|
if (pending.length === 0) {
|
|
590
|
+
// "Nada a fazer" para o spec-kit, mas há o que fazer no board: se TODAS as
|
|
591
|
+
// Stories estão em Code Review+, é exatamente a condição de avanço da
|
|
592
|
+
// Feature. Sem isso, uma Feature que ficou para trás (executor que concluiu
|
|
593
|
+
// sem mover o card, run interrompido) não tinha como ser destravada —
|
|
594
|
+
// reexecutar o implement caía aqui e retornava.
|
|
595
|
+
await applyBoardMoves({ token, dryRun, moves: planBoardMoves('success', {
|
|
596
|
+
feature: { nodeId: feature.node_id, number: feature.number },
|
|
597
|
+
}) });
|
|
545
598
|
p.outro(`Todas as ${stories.length} story(ies) da Feature #${feature.number} já estão implementadas — nada a fazer.`);
|
|
546
599
|
return;
|
|
547
600
|
}
|
|
@@ -660,13 +713,26 @@ async function implementFeature({ token, owner, repo, config, feature, featureDi
|
|
|
660
713
|
` Próximo: acompanhe os PRs de cada Story; a Feature avança para ${STAGE_CODE_REVIEW} após a última.`,
|
|
661
714
|
onSuccess: async () => {
|
|
662
715
|
// Execução única para todas as pendentes: no sucesso, Tasks → Done e
|
|
663
|
-
// Stories → Code Review
|
|
716
|
+
// Stories → Code Review.
|
|
664
717
|
for (const s of pending) {
|
|
665
718
|
await applyBoardMoves({ token, moves: planBoardMoves('success', {
|
|
666
719
|
story: { nodeId: s.nodeId, number: s.number },
|
|
667
720
|
tasks: s.tasks || [],
|
|
668
721
|
}) });
|
|
669
722
|
}
|
|
723
|
+
// E a Feature em seguida: neste ponto TODAS as suas Stories estão em
|
|
724
|
+
// Code Review ou além — as `pending` acabaram de ir, as `skipped` já
|
|
725
|
+
// estavam lá (é esse o critério que as pulou). Não é preciso reconsultar
|
|
726
|
+
// o board para saber disso.
|
|
727
|
+
//
|
|
728
|
+
// Antes isso era prosa no contexto ("avance a Feature somente se TODAS
|
|
729
|
+
// já estiverem em Code Review") e ficava por conta do LLM, com a Action
|
|
730
|
+
// do PR como plano B. Sob o dev-agent, que trabalha num branch único e
|
|
731
|
+
// não abre PR, nenhum dos dois acontecia: as 6 Stories da feature #18
|
|
732
|
+
// foram para Code Review e a Feature ficou em Desenvolvimento.
|
|
733
|
+
await applyBoardMoves({ token, moves: planBoardMoves('success', {
|
|
734
|
+
feature: { nodeId: feature.node_id, number: feature.number },
|
|
735
|
+
}) });
|
|
670
736
|
},
|
|
671
737
|
});
|
|
672
738
|
}
|
|
@@ -803,7 +869,8 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
|
|
|
803
869
|
const featureSubs = await listSubIssues(token, feature.nodeId).catch(() => []);
|
|
804
870
|
siblingStories = featureSubs
|
|
805
871
|
.filter(s => detectIssueType({ title: s.title }) === 'Story' && s.number !== issue.number)
|
|
806
|
-
|
|
872
|
+
// nodeId serve à consulta de Etapa no fim da Story (avanço da Feature).
|
|
873
|
+
.map(s => ({ number: s.number, title: s.title, nodeId: s.nodeId }));
|
|
807
874
|
}
|
|
808
875
|
|
|
809
876
|
// 4c. Comentários das issues (best-effort) — revisões e correções vivem nos
|
|
@@ -872,10 +939,33 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
|
|
|
872
939
|
outroSuccess:
|
|
873
940
|
`${chalk.green('✓')} Implementação acionada para ${type} #${issueNumber}.\n` +
|
|
874
941
|
' Próximo: revise as mudanças e abra o PR — o board já foi atualizado.',
|
|
875
|
-
onSuccess: () =>
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
942
|
+
onSuccess: async () => {
|
|
943
|
+
await applyBoardMoves({ token, moves: planBoardMoves('success', {
|
|
944
|
+
story: type === 'Story' ? { nodeId: issue.node_id, number: issue.number } : null,
|
|
945
|
+
tasks: tasks.map(t => ({ nodeId: t.nodeId, number: t.number })),
|
|
946
|
+
}) });
|
|
947
|
+
// Última Story da Feature? Então a Feature avança junto.
|
|
948
|
+
//
|
|
949
|
+
// Este é o caminho que o dev-agent realmente percorre: o feature_prompt
|
|
950
|
+
// manda `npx spec-wave implement <story>` uma por vez, então o modo
|
|
951
|
+
// Feature (que avança a Feature no seu próprio onSuccess) nunca roda sob
|
|
952
|
+
// o agente. Sem isto, a Feature dependia só da Action do PR — e quando o
|
|
953
|
+
// PR não nascia, ficava presa em Desenvolvimento com todas as Stories em
|
|
954
|
+
// Code Review.
|
|
955
|
+
//
|
|
956
|
+
// A consulta é por irmã e só acontece aqui, ao fim de uma Story: no pior
|
|
957
|
+
// caso é uma leitura por Story da Feature ao longo de toda a execução.
|
|
958
|
+
if (type !== 'Story' || !feature?.nodeId) return;
|
|
959
|
+
// Sem irmãs, esta era a única Story: a Feature avança direto (nada a
|
|
960
|
+
// consultar). Com irmãs, todas precisam já estar em Code Review+.
|
|
961
|
+
if (siblingStories.length > 0) {
|
|
962
|
+
const stageOf = await fetchStagesOf(token, siblingStories, { dryRun });
|
|
963
|
+
if (!featureCanAdvance(siblingStories, stageOf)) return;
|
|
964
|
+
}
|
|
965
|
+
await applyBoardMoves({ token, dryRun, moves: planBoardMoves('success', {
|
|
966
|
+
feature: { nodeId: feature.nodeId, number: feature.number },
|
|
967
|
+
}) });
|
|
968
|
+
},
|
|
879
969
|
});
|
|
880
970
|
}
|
|
881
971
|
|
package/src/config.mjs
CHANGED
|
@@ -334,6 +334,10 @@ export const LABEL_CRITIQUE_FAILED = 'spec-wave:critique-failed';
|
|
|
334
334
|
export const LABEL_DECOMPOSED = 'spec-wave:decomposed';
|
|
335
335
|
export const LABEL_DECOMPOSE_READY = 'spec-wave:decompose-ready';
|
|
336
336
|
export const LABEL_NEEDS_HUMAN = 'spec-wave:needs-human';
|
|
337
|
+
// Achado GRAVE que o Tech Leader aceitou como risco conhecido. Fica na issue
|
|
338
|
+
// depois que a crítica passa: sem ela, o risco aceito some da vista assim que a
|
|
339
|
+
// rodada seguinte roda limpa, e ninguém mais sabe que houve uma decisão.
|
|
340
|
+
export const LABEL_RISK_ACCEPTED = 'spec-wave:risk-accepted';
|
|
337
341
|
|
|
338
342
|
export const TRIGGER_LABELS = [
|
|
339
343
|
{ name: 'spec-wave:spec', color: 'BFD4F2', description: 'Gerar spec.md via GitHub Action' },
|
|
@@ -357,6 +361,7 @@ export const TRIGGER_LABELS = [
|
|
|
357
361
|
{ name: LABEL_DECOMPOSE_READY, color: '0E8A16', description: 'Rascunho de decomposição pronto para revisão humana' },
|
|
358
362
|
{ name: LABEL_CRITIQUE_FAILED, color: 'B60205', description: 'Crítica adversarial apontou contradições graves' },
|
|
359
363
|
{ name: LABEL_NEEDS_HUMAN, color: 'B60205', description: 'Crítica reprovou N vezes seguidas — precisa de revisão humana' },
|
|
364
|
+
{ name: LABEL_RISK_ACCEPTED, color: 'D93F0B', description: 'Tech Leader aceitou finding(s) grave(s) como risco conhecido' },
|
|
360
365
|
{ name: LABEL_DECOMPOSED, color: 'EDEDED', description: 'Feature já decomposta em Stories e Tasks' },
|
|
361
366
|
];
|
|
362
367
|
|
package/src/lib/critique.mjs
CHANGED
|
@@ -265,6 +265,251 @@ export function critiqueMarker({ kind, attempt, verdict }) {
|
|
|
265
265
|
return `<!-- spec-wave:critique kind=${kind} attempt=${attempt} verdict=${verdict} -->`;
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// Decisões do Tech Leader sobre os findings
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
//
|
|
272
|
+
// A crítica reprova; quem decide o que fazer é o TL. Sem isso, o único caminho
|
|
273
|
+
// era corrigir o documento — e quando spec e plan se contradizem, corrigir o
|
|
274
|
+
// artefato criticado não alcança a causa: a crítica reprova de novo, com o
|
|
275
|
+
// achado oposto, indefinidamente.
|
|
276
|
+
//
|
|
277
|
+
// Três desfechos por finding, além do default (corrigir e rodar de novo):
|
|
278
|
+
// • `plano-correto` — o TL afirma que o documento está certo. O achado NÃO é
|
|
279
|
+
// suprimido: vai para a próxima crítica como contexto, para ela levar a
|
|
280
|
+
// afirmação em conta em vez de repetir o mesmo raciocínio.
|
|
281
|
+
// • `ignorar` — o achado é conhecido e aceito. Sai da conta que bloqueia.
|
|
282
|
+
// • (sem decisão) — comportamento de hoje: bloqueia se for grave.
|
|
283
|
+
//
|
|
284
|
+
// A decisão é por ITEM: uma crítica com três achados pode ter um ignorado e
|
|
285
|
+
// dois a corrigir. E pode carregar uma instrução livre para a próxima rodada.
|
|
286
|
+
//
|
|
287
|
+
// Persistência: comentário na issue com marcador, como a própria crítica. É o
|
|
288
|
+
// que mantém CLI, Actions e UI enxergando o mesmo estado — o GitHub segue sendo
|
|
289
|
+
// a fonte de verdade, e o histórico da decisão fica auditável.
|
|
290
|
+
|
|
291
|
+
export const CRITIQUE_DISPOSITIONS = ['plano-correto', 'ignorar'];
|
|
292
|
+
const DISPOSITION_SET = new Set(CRITIQUE_DISPOSITIONS);
|
|
293
|
+
|
|
294
|
+
export const DECISIONS_MARKER_RE =
|
|
295
|
+
/<!--\s*spec-wave:critique-decisions\s+kind=(?<kind>[a-z]+)\s*-->/g;
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Impressão digital de um finding (função PURA).
|
|
299
|
+
*
|
|
300
|
+
* A decisão precisa sobreviver à rodada seguinte, e a única coisa estável entre
|
|
301
|
+
* rodadas é o CONTEÚDO do achado — não há id vindo do modelo. Ancorar no sha do
|
|
302
|
+
* documento não serve: no caso típico (corrigir dois achados, ignorar um) o
|
|
303
|
+
* documento muda por causa das correções e a decisão sobre o terceiro expiraria
|
|
304
|
+
* junto, que é exatamente o que se quer evitar.
|
|
305
|
+
*
|
|
306
|
+
* Normaliza espaços, caixa e acentos para tolerar reformulação cosmética do
|
|
307
|
+
* modelo. Reformulação material muda a digital — e aí é outro achado, que volta
|
|
308
|
+
* a ser levantado de propósito.
|
|
309
|
+
*/
|
|
310
|
+
export function findingFingerprint({ kind = 'plan', anchor = null, text = '' } = {}) {
|
|
311
|
+
const norm = (s) => String(s || '')
|
|
312
|
+
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
|
313
|
+
.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
314
|
+
const base = `${norm(kind)}|${norm(anchor)}|${norm(text)}`;
|
|
315
|
+
// FNV-1a de 32 bits: curto o bastante para caber no comentário e determinístico
|
|
316
|
+
// entre execuções (o hash do Node varia por processo).
|
|
317
|
+
let h = 0x811c9dc5;
|
|
318
|
+
for (let i = 0; i < base.length; i += 1) {
|
|
319
|
+
h ^= base.charCodeAt(i);
|
|
320
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
321
|
+
}
|
|
322
|
+
return h.toString(16).padStart(8, '0');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Monta o marcador do comentário de decisões (função PURA). */
|
|
326
|
+
export function decisionsMarker({ kind }) {
|
|
327
|
+
return `<!-- spec-wave:critique-decisions kind=${kind} -->`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Comentário de decisões do TL (função PURA).
|
|
332
|
+
*
|
|
333
|
+
* O corpo é legível por humano E parseável: o bloco JSON cercado é o que a
|
|
334
|
+
* próxima rodada lê. Escrever em prosa e reparsear com regex seria frágil — o
|
|
335
|
+
* texto do TL é livre e pode conter qualquer coisa.
|
|
336
|
+
*/
|
|
337
|
+
export function renderDecisionsComment({
|
|
338
|
+
kind = 'plan', decisions = [], guidance = null, author = null,
|
|
339
|
+
} = {}) {
|
|
340
|
+
const porTipo = {
|
|
341
|
+
'plano-correto': decisions.filter(d => d.disposition === 'plano-correto'),
|
|
342
|
+
ignorar: decisions.filter(d => d.disposition === 'ignorar'),
|
|
343
|
+
};
|
|
344
|
+
const linhas = [
|
|
345
|
+
decisionsMarker({ kind }),
|
|
346
|
+
'',
|
|
347
|
+
`🧭 **Decisões do Tech Leader sobre a crítica**${author ? ` — por @${author}` : ''}`,
|
|
348
|
+
'',
|
|
349
|
+
];
|
|
350
|
+
if (porTipo['plano-correto'].length > 0) {
|
|
351
|
+
linhas.push('### ✅ Documento está correto');
|
|
352
|
+
linhas.push('');
|
|
353
|
+
linhas.push('> A próxima crítica recebe estas afirmações como contexto.');
|
|
354
|
+
linhas.push('');
|
|
355
|
+
for (const d of porTipo['plano-correto']) {
|
|
356
|
+
linhas.push(`- ${d.anchor ? `**${d.anchor}** — ` : ''}${d.summary || d.fingerprint}`);
|
|
357
|
+
if (d.note) linhas.push(` - Justificativa: ${d.note}`);
|
|
358
|
+
}
|
|
359
|
+
linhas.push('');
|
|
360
|
+
}
|
|
361
|
+
if (porTipo.ignorar.length > 0) {
|
|
362
|
+
linhas.push('### ⚠️ Aceitos como risco');
|
|
363
|
+
linhas.push('');
|
|
364
|
+
for (const d of porTipo.ignorar) {
|
|
365
|
+
linhas.push(`- ${d.anchor ? `**${d.anchor}** — ` : ''}${d.summary || d.fingerprint}`);
|
|
366
|
+
if (d.note) linhas.push(` - Justificativa: ${d.note}`);
|
|
367
|
+
}
|
|
368
|
+
linhas.push('');
|
|
369
|
+
}
|
|
370
|
+
if (guidance) {
|
|
371
|
+
linhas.push('### 🧭 Instrução para a próxima crítica');
|
|
372
|
+
linhas.push('');
|
|
373
|
+
linhas.push(guidance);
|
|
374
|
+
linhas.push('');
|
|
375
|
+
}
|
|
376
|
+
linhas.push('```json spec-wave:decisions');
|
|
377
|
+
linhas.push(JSON.stringify({ kind, guidance: guidance || null, decisions }, null, 2));
|
|
378
|
+
linhas.push('```');
|
|
379
|
+
return linhas.join('\n');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const DECISIONS_JSON_RE = /```json spec-wave:decisions\s*\n([\s\S]*?)\n```/;
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Decisões vigentes da issue (função PURA).
|
|
386
|
+
*
|
|
387
|
+
* Comentários em ordem cronológica; o mais recente de cada `kind` vence — o TL
|
|
388
|
+
* pode revisar o que decidiu, e a última palavra é a que vale. Payload
|
|
389
|
+
* inválido é IGNORADO em silêncio: uma decisão ilegível não pode virar
|
|
390
|
+
* "ignorar tudo" por acidente.
|
|
391
|
+
*
|
|
392
|
+
* @returns {{ byFingerprint: Map<string,object>, guidance: string|null }}
|
|
393
|
+
*/
|
|
394
|
+
export function parseCritiqueDecisions(comments = [], kind = 'plan') {
|
|
395
|
+
let vigente = null;
|
|
396
|
+
for (const comment of comments) {
|
|
397
|
+
const body = String(comment?.body || '');
|
|
398
|
+
DECISIONS_MARKER_RE.lastIndex = 0;
|
|
399
|
+
const m = DECISIONS_MARKER_RE.exec(body);
|
|
400
|
+
if (!m || m.groups.kind !== kind) continue;
|
|
401
|
+
const bloco = body.match(DECISIONS_JSON_RE);
|
|
402
|
+
if (!bloco) continue;
|
|
403
|
+
try {
|
|
404
|
+
const payload = JSON.parse(bloco[1]);
|
|
405
|
+
if (!Array.isArray(payload?.decisions)) continue;
|
|
406
|
+
vigente = payload;
|
|
407
|
+
} catch { /* payload ilegível não vira decisão */ }
|
|
408
|
+
}
|
|
409
|
+
const byFingerprint = new Map();
|
|
410
|
+
for (const d of vigente?.decisions || []) {
|
|
411
|
+
if (!d?.fingerprint || !DISPOSITION_SET.has(d.disposition)) continue;
|
|
412
|
+
byFingerprint.set(String(d.fingerprint), d);
|
|
413
|
+
}
|
|
414
|
+
return { byFingerprint, guidance: vigente?.guidance || null };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Cruza os findings recém-produzidos com as decisões vigentes (função PURA).
|
|
419
|
+
*
|
|
420
|
+
* `blocking` é o que decide se a label de reprova entra: um grave decidido sai
|
|
421
|
+
* da conta. `accepted` alimenta o comentário de aceite de risco — sem esse
|
|
422
|
+
* registro, o risco aceito desaparece assim que a crítica seguinte roda limpa.
|
|
423
|
+
*
|
|
424
|
+
* @returns {{ findings: Array, blocking: Array, accepted: Array, asserted: Array }}
|
|
425
|
+
*/
|
|
426
|
+
export function applyCritiqueDecisions(findings = [], { byFingerprint = new Map() } = {}, kind = 'plan') {
|
|
427
|
+
const anotados = findings.map((f) => {
|
|
428
|
+
const fingerprint = findingFingerprint({ kind, anchor: f.anchor, text: f.text });
|
|
429
|
+
const decision = byFingerprint.get(fingerprint) || null;
|
|
430
|
+
return { ...f, fingerprint, decision: decision ? decision.disposition : null, decisionNote: decision?.note || null };
|
|
431
|
+
});
|
|
432
|
+
return {
|
|
433
|
+
findings: anotados,
|
|
434
|
+
// Grave sem decisão = bloqueia, como sempre foi.
|
|
435
|
+
blocking: anotados.filter(f => f.severity === 'grave' && f.decision === null),
|
|
436
|
+
accepted: anotados.filter(f => f.decision === 'ignorar'),
|
|
437
|
+
asserted: anotados.filter(f => f.decision === 'plano-correto'),
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Comentário de aceite de risco (função PURA).
|
|
443
|
+
*
|
|
444
|
+
* Um grave aceito pelo TL deixa de bloquear — e é justamente por isso que
|
|
445
|
+
* precisa de registro permanente. Sem este comentário, o risco desaparece da
|
|
446
|
+
* vista assim que a crítica seguinte roda limpa, e ninguém mais sabe que houve
|
|
447
|
+
* uma decisão nem qual foi a justificativa.
|
|
448
|
+
*/
|
|
449
|
+
export function renderRiskAcceptedComment({ kind = 'plan', accepted = [] } = {}) {
|
|
450
|
+
const linhas = [
|
|
451
|
+
`<!-- spec-wave:risk-accepted kind=${kind} -->`,
|
|
452
|
+
'',
|
|
453
|
+
'⚠️ **Findings graves aceitos como risco pelo Tech Leader**',
|
|
454
|
+
'',
|
|
455
|
+
'Estes achados **não** bloquearam o avanço — a decisão foi humana e explícita:',
|
|
456
|
+
'',
|
|
457
|
+
];
|
|
458
|
+
for (const f of accepted) {
|
|
459
|
+
linhas.push(`- ${f.anchor ? `**${f.anchor}** — ` : ''}${f.text || f.summary || ''}`);
|
|
460
|
+
if (f.decisionNote || f.note) linhas.push(` - Justificativa: ${f.decisionNote || f.note}`);
|
|
461
|
+
}
|
|
462
|
+
linhas.push('');
|
|
463
|
+
linhas.push(`A label \`spec-wave:risk-accepted\` fica na issue como marca dessa decisão.`);
|
|
464
|
+
return linhas.join('\n');
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Bloco que entra no prompt da próxima crítica (função PURA).
|
|
469
|
+
*
|
|
470
|
+
* As afirmações do TL entram como CONTEXTO, não como ordem de silenciar: o
|
|
471
|
+
* objetivo é que a crítica reconsidere com a informação nova, e diga se
|
|
472
|
+
* discorda. Silenciar por decreto transformaria o portão em teatro.
|
|
473
|
+
*/
|
|
474
|
+
export function renderDecisionsForPrompt({ decisions = [], guidance = null } = {}) {
|
|
475
|
+
// Aceita tanto os REGISTROS persistidos (que guardam `summary`) quanto os
|
|
476
|
+
// findings anotados (que guardam `text`) — os dois chamadores existem.
|
|
477
|
+
const texto = (d) => d.text || d.summary || '';
|
|
478
|
+
const asserted = decisions.filter(d => d.disposition === 'plano-correto' || d.decision === 'plano-correto');
|
|
479
|
+
const accepted = decisions.filter(d => d.disposition === 'ignorar' || d.decision === 'ignorar');
|
|
480
|
+
if (asserted.length === 0 && accepted.length === 0 && !guidance) return '';
|
|
481
|
+
const linhas = ['## Decisões do Tech Leader sobre a crítica anterior', ''];
|
|
482
|
+
if (asserted.length > 0) {
|
|
483
|
+
linhas.push(
|
|
484
|
+
'O Tech Leader AFIRMA que os pontos abaixo estão corretos no documento. ' +
|
|
485
|
+
'Leve a afirmação em conta: se ela resolve o problema, NÃO repita o finding. ' +
|
|
486
|
+
'Se você ainda discorda, pode levantá-lo de novo — mas explique por que a ' +
|
|
487
|
+
'afirmação do Tech Leader não procede.',
|
|
488
|
+
''
|
|
489
|
+
);
|
|
490
|
+
for (const f of asserted) {
|
|
491
|
+
linhas.push(`- ${f.anchor ? `**${f.anchor}** — ` : ''}${texto(f)}`);
|
|
492
|
+
const nota = f.note || f.decisionNote;
|
|
493
|
+
if (nota) linhas.push(` - Tech Leader: ${nota}`);
|
|
494
|
+
}
|
|
495
|
+
linhas.push('');
|
|
496
|
+
}
|
|
497
|
+
if (accepted.length > 0) {
|
|
498
|
+
linhas.push(
|
|
499
|
+
'Os pontos abaixo foram ACEITOS COMO RISCO conhecido. Não os levante de novo.',
|
|
500
|
+
''
|
|
501
|
+
);
|
|
502
|
+
for (const f of accepted) {
|
|
503
|
+
linhas.push(`- ${f.anchor ? `**${f.anchor}** — ` : ''}${texto(f)}`);
|
|
504
|
+
}
|
|
505
|
+
linhas.push('');
|
|
506
|
+
}
|
|
507
|
+
if (guidance) {
|
|
508
|
+
linhas.push('### Instrução do Tech Leader para esta rodada', '', guidance, '');
|
|
509
|
+
}
|
|
510
|
+
return linhas.join('\n');
|
|
511
|
+
}
|
|
512
|
+
|
|
268
513
|
/**
|
|
269
514
|
* Quantas reprovas consecutivas desta crítica já existem na issue (função PURA).
|
|
270
515
|
*
|
|
@@ -288,6 +533,7 @@ export function resolveCritiqueAttempt({
|
|
|
288
533
|
} = {}) {
|
|
289
534
|
const names = labelNames(labels);
|
|
290
535
|
if (names.includes(LABEL_NEEDS_HUMAN)) return { attempt: 0, previous: 0, blocked: true };
|
|
536
|
+
|
|
291
537
|
if (!names.includes(LABEL_CRITIQUE_FAILED)) return { attempt: 1, previous: 0, blocked: false };
|
|
292
538
|
|
|
293
539
|
let previous = 0;
|
|
@@ -398,6 +644,23 @@ export function renderCritiqueMarkdown({
|
|
|
398
644
|
if (graves.length > 0) parts.push(`### ❌ Graves\n\n${bullets(graves)}`);
|
|
399
645
|
if (menores.length > 0) parts.push(`### ⚠️ Menores\n\n${bullets(menores)}`);
|
|
400
646
|
parts.push((KIND_TRAILER[kind] || KIND_TRAILER.plan)(graves.length > 0));
|
|
647
|
+
// Findings estruturados, com a MESMA digital que o portão usa. É o que
|
|
648
|
+
// permite decidir item a item numa UI: parsear os bullets de volta seria
|
|
649
|
+
// frágil, e uma digital recalculada de forma diferente não casaria com a
|
|
650
|
+
// decisão gravada. Quem escreve a digital é quem a compara.
|
|
651
|
+
parts.push([
|
|
652
|
+
'```json spec-wave:findings',
|
|
653
|
+
JSON.stringify({
|
|
654
|
+
kind,
|
|
655
|
+
findings: findings.map(f => ({
|
|
656
|
+
fingerprint: findingFingerprint({ kind, anchor: f.anchor, text: f.text }),
|
|
657
|
+
severity: f.severity,
|
|
658
|
+
anchor: f.anchor || null,
|
|
659
|
+
text: sanitizeFindingText(f.text),
|
|
660
|
+
})),
|
|
661
|
+
}, null, 2),
|
|
662
|
+
'```',
|
|
663
|
+
].join('\n'));
|
|
401
664
|
return parts.join('\n\n');
|
|
402
665
|
}
|
|
403
666
|
|
|
@@ -430,7 +693,7 @@ export function renderCritiqueMarkdown({
|
|
|
430
693
|
export async function runCritique({
|
|
431
694
|
kind, spec, plan, techContextYaml, decomposition, bugDoc, bugReport,
|
|
432
695
|
attempt = 1, maxAttempts = DEFAULT_MAX_CRITIQUE_ATTEMPTS,
|
|
433
|
-
model, labels = [], usage, cwd,
|
|
696
|
+
model, labels = [], usage, cwd, decisions = null,
|
|
434
697
|
} = {}) {
|
|
435
698
|
const sections = [];
|
|
436
699
|
if (spec) sections.push(`## spec.md\n\n${spec}`);
|
|
@@ -447,6 +710,15 @@ export async function runCritique({
|
|
|
447
710
|
// tem que explicar OS SINTOMAS RELATADOS, não sintomas plausíveis quaisquer.
|
|
448
711
|
if (bugReport) sections.push(`## Relato original (issue e comentários)\n\n${bugReport}`);
|
|
449
712
|
if (bugDoc) sections.push(`## bug.md\n\n${bugDoc}`);
|
|
713
|
+
// Decisões do TL sobre a rodada anterior entram por ÚLTIMO: são o contexto
|
|
714
|
+
// mais recente, e a crítica precisa lê-las depois de já ter visto os documentos.
|
|
715
|
+
const decisoesBloco = decisions
|
|
716
|
+
? renderDecisionsForPrompt({
|
|
717
|
+
decisions: [...(decisions.byFingerprint?.values() || [])],
|
|
718
|
+
guidance: decisions.guidance,
|
|
719
|
+
})
|
|
720
|
+
: '';
|
|
721
|
+
if (decisoesBloco) sections.push(decisoesBloco);
|
|
450
722
|
const userContent = sections.join('\n\n') || '(nenhum documento fornecido)';
|
|
451
723
|
|
|
452
724
|
const report = await generateStructured(buildSystemPrompt(kind, cwd), userContent, {
|
|
@@ -70,6 +70,16 @@ export function planBoardMoves(phase, {
|
|
|
70
70
|
moves.push({ nodeId: bug.nodeId, label: `Bug #${bug.number}`,
|
|
71
71
|
stage: STAGE_CODE_REVIEW, status: PROGRESS_TODO, statusFallback: false });
|
|
72
72
|
}
|
|
73
|
+
// Feature: só o modo Feature passa `feature` aqui, e só depois de TODAS as
|
|
74
|
+
// suas Stories terem ido para Code Review — quem decide é o chamador, que
|
|
75
|
+
// conhece as pendentes e as puladas. Antes, o avanço da Feature dependia da
|
|
76
|
+
// Action disparada pelo PR; quando o PR não nascia (o dev-agent trabalha
|
|
77
|
+
// num branch único e não abre PR), a Feature ficava presa em
|
|
78
|
+
// Desenvolvimento com todas as Stories já em Code Review.
|
|
79
|
+
if (feature?.nodeId) {
|
|
80
|
+
moves.push({ nodeId: feature.nodeId, label: `Feature #${feature.number}`,
|
|
81
|
+
stage: STAGE_CODE_REVIEW, status: PROGRESS_TODO, statusFallback: false });
|
|
82
|
+
}
|
|
73
83
|
}
|
|
74
84
|
return moves;
|
|
75
85
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// O modelo às vezes ENTREGA o documento em vez de SER o documento.
|
|
2
|
+
//
|
|
3
|
+
// Encontrado no smoke test do RFC-004: o bug.md commitado começava com
|
|
4
|
+
// "Investigação concluída. […] segue o conteúdo completo do bug.md para ser
|
|
5
|
+
// salvo na raiz do repositório:", trazia o documento dentro de uma cerca
|
|
6
|
+
// ```markdown, e terminava com "Resumo do que foi feito: …".
|
|
7
|
+
//
|
|
8
|
+
// Passou pelo `validate` porque a checagem é `content.includes('# Seção')` —
|
|
9
|
+
// e as seções estavam lá, dentro da cerca. O arquivo versionado ficou com
|
|
10
|
+
// texto conversacional que ninguém pediu, e o `implement` entrega isso ao
|
|
11
|
+
// executor como se fosse o documento.
|
|
12
|
+
//
|
|
13
|
+
// Não dá para resolver só no prompt: pedir "responda apenas com o markdown"
|
|
14
|
+
// já está lá, e o modelo desobedece quando acha que precisa explicar algo.
|
|
15
|
+
// Este módulo trata a saída, que é o único ponto sob nosso controle.
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Extrai o documento de uma resposta que pode vir embrulhada (função PURA).
|
|
19
|
+
*
|
|
20
|
+
* Dois formatos tratados, nesta ordem:
|
|
21
|
+
* 1. **Cerca envolvente** — o documento inteiro dentro de ```markdown … ```,
|
|
22
|
+
* com preâmbulo antes e/ou epílogo depois. Só é tratada como envolvente a
|
|
23
|
+
* cerca que aparece ANTES do primeiro heading: uma cerca depois do
|
|
24
|
+
* primeiro heading é bloco de código legítimo DO documento.
|
|
25
|
+
* 2. **Preâmbulo solto** — texto antes do primeiro heading, sem cerca.
|
|
26
|
+
*
|
|
27
|
+
* Conteúdo já limpo passa intacto.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} raw resposta do modelo
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*/
|
|
32
|
+
export function unwrapGeneratedDoc(raw) {
|
|
33
|
+
const text = String(raw ?? '').trim();
|
|
34
|
+
if (!text) return text;
|
|
35
|
+
|
|
36
|
+
const lines = text.split('\n');
|
|
37
|
+
const abreCerca = /^\s*```+\s*(markdown|md)?\s*$/i;
|
|
38
|
+
const fechaCerca = /^\s*```+\s*$/;
|
|
39
|
+
const heading = /^\s{0,3}#{1,6}\s+\S/;
|
|
40
|
+
|
|
41
|
+
const primeiroHeading = lines.findIndex(l => heading.test(l));
|
|
42
|
+
const primeiraCerca = lines.findIndex(l => abreCerca.test(l));
|
|
43
|
+
|
|
44
|
+
// (1) Cerca envolvente: abre antes de qualquer heading e fecha depois.
|
|
45
|
+
if (primeiraCerca !== -1 && (primeiroHeading === -1 || primeiraCerca < primeiroHeading)) {
|
|
46
|
+
// A cerca de fechamento é a ÚLTIMA do texto — cercas internas pertencem ao
|
|
47
|
+
// documento (blocos de código nas seções) e não podem encerrar o embrulho.
|
|
48
|
+
let ultimaCerca = -1;
|
|
49
|
+
for (let i = lines.length - 1; i > primeiraCerca; i -= 1) {
|
|
50
|
+
if (fechaCerca.test(lines[i])) { ultimaCerca = i; break; }
|
|
51
|
+
}
|
|
52
|
+
if (ultimaCerca > primeiraCerca) {
|
|
53
|
+
const dentro = lines.slice(primeiraCerca + 1, ultimaCerca).join('\n').trim();
|
|
54
|
+
if (dentro) return dentro;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// (2) Preâmbulo sem cerca: começa no primeiro heading.
|
|
59
|
+
if (primeiroHeading > 0) {
|
|
60
|
+
return lines.slice(primeiroHeading).join('\n').trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return text;
|
|
64
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spec-wave",
|
|
3
3
|
"displayName": "Spec Wave",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.17.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",
|