@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.
@@ -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 = { plan: 'plan.md', stories: 'decomposition.md', bug: 'bug.md' };
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 = { plan: 'plan/critique', stories: 'decompose/critique', bug: 'bug/critique' };
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(k => k !== 'severity' && k !== 'text' && k !== 'anchor');
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
- return { severity, ...(anchor ? { anchor } : {}), text: item.text.trim() };
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
- const menores = findings.filter(f => f.severity === 'menor');
630
- const parts = [
631
- critiqueMarker({ kind, attempt, verdict: graves.length > 0 ? 'grave' : 'limpa' }),
632
- `🔎 **Crítica adversarial (spec-wave)** ${KIND_LABEL[kind] || KIND_LABEL.plan} · ` +
633
- `tentativa ${attempt}/${maxAttempts}${model ? ` · modelo \`${model}\`` : ''}`,
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
- const { grave, findings } = report.value;
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({ kind, findings, attempt, maxAttempts, model: report.model }),
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
- blocks.push([
229
+ const campos = [
211
230
  `**User story:** ${flatten(story?.userStory) || '—'}`,
212
231
  `**Depende de:** ${deps.length ? deps.map(d => `Story ${d + 1}`).join(', ') : '—'}`,
213
- ].join('\n'));
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
- if (!us && !dep) break;
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
- return { anchor, title: requireTitle(s.title, anchor), body: readBody(s.lines) };
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
- story.tasks.push({ anchor, title: requireTitle(section.title, anchor), body: readBody(section.lines) });
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 — …")');
@@ -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
- git('git pull --rebase');
124
- git('git push');
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
- * `generateDocument`/`generateStructured` fazem UMA chamada de completions: não
191
- * há loop de tool use, então o modelo não tem `Read`/`Glob`/`Grep` por mais que
192
- * o frontmatter os declare. Um prompt que afirme possuir ferramentas nesse
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
  *