@spec-wave/cli 0.16.4 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spec-wave/cli",
3
- "version": "0.16.4",
3
+ "version": "0.17.0",
4
4
  "description": "Setup spec-driven GitHub workflow with Projects v2, labels, issue templates, and AI-powered Actions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 { runCritique, resolveCritiqueAttempt, renderNeedsHumanComment } from '../lib/critique.mjs';
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
- if (critique.grave) {
349
- console.log('Crítica adversarial apontou findings GRAVESnenhum item foi criado.');
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);
@@ -7,12 +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, DEFAULT_MAX_CRITIQUE_ATTEMPTS, labelNames,
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';
13
14
  import { unwrapGeneratedDoc } from '../lib/unwrap-doc.mjs';
14
15
  import {
15
16
  runCritique, resolveCritiqueAttempt, renderNeedsHumanComment,
17
+ parseCritiqueDecisions, applyCritiqueDecisions, renderRiskAcceptedComment,
16
18
  } from '../lib/critique.mjs';
17
19
  import { recordUsage } from '../lib/usage-report.mjs';
18
20
  import { slugify } from '../lib/slugify.mjs';
@@ -66,6 +68,9 @@ async function critiquePlan({
66
68
  return [];
67
69
  });
68
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');
69
74
 
70
75
  if (blocked) {
71
76
  console.log(`Teto de ${maxAttempts} tentativas de crítica atingido — exigindo revisão humana.`);
@@ -82,6 +87,7 @@ async function critiquePlan({
82
87
  try {
83
88
  critique = await runCritique({
84
89
  kind: 'plan', spec, plan, techContextYaml, attempt, maxAttempts, model, labels, usage,
90
+ decisions,
85
91
  });
86
92
  } catch (err) {
87
93
  console.warn(`Crítica adversarial não concluiu: ${err.message}`);
@@ -98,9 +104,19 @@ async function critiquePlan({
98
104
  await commentOnIssue(token, owner, repo, issueNumber, critique.markdown)
99
105
  .catch(err => console.warn(`Falha ao comentar a crítica: ${err.message}`));
100
106
 
101
- if (critique.grave) {
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) {
102
118
  await addLabel(token, owner, repo, issueNumber, LABEL_CRITIQUE_FAILED);
103
- console.log(`Crítica adversarial apontou findings GRAVES — label ${LABEL_CRITIQUE_FAILED} aplicada.`);
119
+ console.log(`Crítica adversarial apontou ${blocking.length} finding(s) GRAVE(s) sem decisão — label ${LABEL_CRITIQUE_FAILED} aplicada.`);
104
120
  return;
105
121
  }
106
122
  // Crítica limpa remove o bloqueio anterior — é isso que zera o contador de
@@ -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 { project, error: projectError } = loadProjectConfig();
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 (a Feature fica com a Action de PR/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
- .map(s => ({ number: s.number, title: s.title }));
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: () => applyBoardMoves({ token, moves: planBoardMoves('success', {
876
- story: type === 'Story' ? { nodeId: issue.node_id, number: issue.number } : null,
877
- tasks: tasks.map(t => ({ nodeId: t.nodeId, number: t.number })),
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
 
@@ -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
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "spec-wave",
3
3
  "displayName": "Spec Wave",
4
- "version": "0.16.4",
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",