@spec-wave/cli 0.15.0 → 0.16.1

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.
Files changed (78) hide show
  1. package/README.md +1 -0
  2. package/bin/spec-wave.mjs +44 -5
  3. package/package.json +8 -2
  4. package/src/agent/anthropic-agent.mjs +337 -0
  5. package/src/agent/errors.mjs +33 -0
  6. package/src/agent/index.mjs +108 -0
  7. package/src/agent/openrouter-agent.mjs +378 -0
  8. package/src/agent/run-types.mjs +59 -0
  9. package/src/agent/telemetry.mjs +54 -0
  10. package/src/agent/tools.mjs +452 -0
  11. package/src/agent/tracing.mjs +106 -0
  12. package/src/api/github-graphql.mjs +23 -1
  13. package/src/api/github-rest.mjs +8 -0
  14. package/src/commands/bug.mjs +8 -0
  15. package/src/commands/code-review.mjs +45 -4
  16. package/src/commands/decompose.mjs +22 -72
  17. package/src/commands/dev-agent.mjs +3 -3
  18. package/src/commands/doctor.mjs +77 -6
  19. package/src/commands/generate-bug.mjs +195 -0
  20. package/src/commands/generate-plan.mjs +19 -44
  21. package/src/commands/generate-spec.mjs +18 -46
  22. package/src/commands/implement.mjs +105 -2
  23. package/src/commands/init.mjs +3 -3
  24. package/src/commands/install-skill.mjs +72 -16
  25. package/src/commands/issue.mjs +9 -7
  26. package/src/commands/move.mjs +11 -1
  27. package/src/commands/qa.mjs +23 -2
  28. package/src/commands/refresh.mjs +171 -5
  29. package/src/commands/triage.mjs +174 -0
  30. package/src/commands/update.mjs +16 -3
  31. package/src/commands/validate.mjs +82 -10
  32. package/src/config.mjs +159 -1
  33. package/src/lib/bug-context.mjs +160 -0
  34. package/src/lib/bug-doc.mjs +51 -0
  35. package/src/lib/bug-triage.mjs +81 -0
  36. package/src/lib/claude.mjs +71 -254
  37. package/src/lib/critique.mjs +43 -30
  38. package/src/lib/flow-run.mjs +145 -0
  39. package/src/lib/implement-board.mjs +12 -1
  40. package/src/lib/plugin-skills.mjs +122 -0
  41. package/src/lib/project-root.mjs +9 -2
  42. package/src/lib/prompt-loader.mjs +257 -0
  43. package/src/lib/skill-file.mjs +35 -0
  44. package/src/plugin/.claude-plugin/plugin.json +20 -0
  45. package/src/plugin/README.md +73 -0
  46. package/src/plugin/skills/bug/SKILL.md +60 -0
  47. package/src/plugin/skills/bug/model-prompt.critique.md +48 -0
  48. package/src/plugin/skills/bug/model-prompt.md +74 -0
  49. package/src/plugin/skills/decompose/SKILL.md +117 -0
  50. package/src/plugin/skills/decompose/model-prompt.critique.md +46 -0
  51. package/src/plugin/skills/decompose/model-prompt.feature.md +69 -0
  52. package/src/plugin/skills/decompose/model-prompt.rfc.md +52 -0
  53. package/src/plugin/skills/doctor/SKILL.md +51 -0
  54. package/src/plugin/skills/fix-pr/SKILL.md +130 -0
  55. package/src/plugin/skills/implement/SKILL.md +102 -0
  56. package/src/plugin/skills/info/SKILL.md +40 -0
  57. package/src/plugin/skills/issue/SKILL.md +63 -0
  58. package/src/plugin/skills/move/SKILL.md +52 -0
  59. package/src/plugin/skills/order/SKILL.md +36 -0
  60. package/src/plugin/skills/plan/SKILL.md +58 -0
  61. package/src/plugin/skills/plan/model-prompt.critique.md +44 -0
  62. package/src/plugin/skills/plan/model-prompt.md +59 -0
  63. package/src/plugin/skills/plan/reference/tech-context.md +56 -0
  64. package/src/plugin/skills/ready/SKILL.md +44 -0
  65. package/src/plugin/skills/rfc/SKILL.md +47 -0
  66. package/src/plugin/skills/setup/SKILL.md +67 -0
  67. package/src/plugin/skills/spec/SKILL.md +55 -0
  68. package/src/plugin/skills/spec/model-prompt.md +61 -0
  69. package/src/plugin/skills/story/SKILL.md +49 -0
  70. package/src/plugin/skills/task/SKILL.md +41 -0
  71. package/src/plugin/skills/triage/SKILL.md +52 -0
  72. package/src/plugin/skills/uninstall/SKILL.md +43 -0
  73. package/src/plugin/skills/update/SKILL.md +51 -0
  74. package/src/plugin/skills/workflow/SKILL.md +158 -0
  75. package/src/templates/skill/SKILL.md +54 -4
  76. package/src/templates/workflows/generate-bug.yml +36 -0
  77. package/src/templates/workflows/validate.yml +2 -1
  78. package/src/ui/wizard.mjs +5 -2
@@ -5,6 +5,8 @@ import {
5
5
  DEFAULT_MAX_CRITIQUE_ATTEMPTS, labelNames,
6
6
  } from '../config.mjs';
7
7
  import { lintLanguage } from './output-lint.mjs';
8
+ import { runAgent } from '../agent/index.mjs';
9
+ import { TruncatedOutputError, isTruncationReason } from '../agent/errors.mjs';
8
10
  import { computeCost } from './usage-report.mjs';
9
11
  import { findConfigPath } from './project-root.mjs';
10
12
 
@@ -180,33 +182,11 @@ export async function withRetry(label, fn, { attempts = RETRY_ATTEMPTS, baseMs =
180
182
  throw lastErr;
181
183
  }
182
184
 
183
- // Truncamento: os dois provedores dizem explicitamente que cortaram a saída no
184
- // teto de tokens OpenRouter (formato OpenAI) em `choices[0].finish_reason`,
185
- // Anthropic em `message.stop_reason`. Ignorar esse campo é o que produzia um
186
- // documento cortado no meio de uma frase, commitado como se estivesse completo.
187
- const TRUNCATION_REASONS = new Set(['length', 'max_tokens']);
188
-
189
- /** Motivo de parada indica saída cortada no teto de tokens? (função PURA) */
190
- export function isTruncationReason(reason) {
191
- return TRUNCATION_REASONS.has(reason);
192
- }
193
-
194
- // Repetir a MESMA requisição depois de truncar dá o mesmo corte — só sobe o
195
- // custo. Por isso NÃO é marcado como transitório: o erro sobe, o Action falha
196
- // visível, destrava a label e comenta na issue o que ajustar.
197
- export class TruncatedOutputError extends Error {
198
- constructor({ provider, model, maxTokens, reason, chars }) {
199
- super(
200
- `Saída truncada pelo teto de tokens (${provider} · ${model} · max_tokens=${maxTokens} · ` +
201
- `motivo=${reason}). Foram gerados ~${chars} caracteres antes do corte. ` +
202
- 'Aumente `ai.maxTokens` (ou `ai.maxTokensByAction`) no .spec-wave.json, ou reduza o ' +
203
- 'tamanho da issue de origem. O documento NÃO foi gravado — um documento cortado ' +
204
- 'passaria na validação de seções e valeria menos que nenhum.'
205
- );
206
- this.name = 'TruncatedOutputError';
207
- this.truncated = true;
208
- }
209
- }
185
+ // `TruncatedOutputError` e `isTruncationReason` saíram daqui para
186
+ // `agent/errors.mjs` quando os backends portados passaram a precisar deles.
187
+ // Re-exportados para não quebrar os 5 consumidores e os testes que importam
188
+ // deste módulo.
189
+ export { TruncatedOutputError, isTruncationReason };
210
190
 
211
191
  // Os parâmetros de sampling foram REMOVIDOS a partir do Claude Opus 4.7 (vale
212
192
  // para 4.8 e 5, Sonnet 5, Fable 5 e Mythos 5): enviar temperature/top_p/top_k
@@ -234,67 +214,11 @@ export function supportsStrictSchema(model) {
234
214
  return MODELS_WITH_STRICT_SCHEMA.test(model || '');
235
215
  }
236
216
 
237
- /**
238
- * Corpo EXATO de `client.messages.create` (função PURA testável sem HTTP).
239
- *
240
- * Com `schema`, força a ferramenta via tool_choice: o modelo não tem como
241
- * responder em texto livre, que é o que produzia JSON sujo na crítica.
242
- * `disable_parallel_tool_use` garante no máximo um bloco tool_use.
243
- */
244
- export function buildAnthropicRequest({
245
- ai, systemPrompt, userContent, temperature, maxTokens, schema, strict,
246
- }) {
247
- return {
248
- model: ai.model,
249
- max_tokens: maxTokens,
250
- // Omitida nos modelos que removeram sampling (Opus 4.7+, Sonnet 5, Fable 5):
251
- // enviá-la devolve 400.
252
- ...(temperature === undefined ? {} : { temperature }),
253
- messages: [{ role: 'user', content: userContent }],
254
- system: systemPrompt,
255
- ...(schema ? {
256
- tools: [{
257
- name: schema.name,
258
- description: schema.description,
259
- input_schema: schema.jsonSchema,
260
- ...(strict ? { strict: true } : {}),
261
- }],
262
- tool_choice: { type: 'tool', name: schema.name, disable_parallel_tool_use: true },
263
- } : {}),
264
- };
265
- }
217
+ // `buildAnthropicRequest` e `buildOpenRouterBody` foram REMOVIDOS: quem monta
218
+ // a requisição agora é o motor (`src/agent/`). No backend openrouter isso é
219
+ // `buildChatCompletionBody`; no anthropic, o subprocesso do Claude Code monta
220
+ // a própria este processo não fala com a API da Anthropic.
266
221
 
267
- /**
268
- * Corpo EXATO do POST da OpenRouter (função PURA — testável sem HTTP).
269
- *
270
- * `provider.require_parameters` é obrigatório junto do response_format: sem ele o
271
- * roteador escolhe endpoints que IGNORAM o schema e devolvem prosa, e o erro só
272
- * apareceria no JSON.parse, três retries depois. Com ele, a recusa é imediata e
273
- * a mensagem diz o que trocar.
274
- */
275
- export function buildOpenRouterBody({
276
- ai, systemPrompt, userContent, temperature, maxTokens, schema,
277
- }) {
278
- return {
279
- model: ai.model,
280
- max_tokens: maxTokens,
281
- // Omitida nos modelos que removeram sampling (Opus 4.7+, Sonnet 5, Fable 5).
282
- ...(temperature === undefined ? {} : { temperature }),
283
- messages: [
284
- { role: 'system', content: systemPrompt },
285
- { role: 'user', content: userContent },
286
- ],
287
- // Pede o bloco `usage` completo na resposta (inclui `cost` em USD).
288
- usage: { include: true },
289
- ...(schema ? {
290
- response_format: {
291
- type: 'json_schema',
292
- json_schema: { name: schema.name, strict: true, schema: schema.jsonSchema },
293
- },
294
- provider: { require_parameters: true },
295
- } : {}),
296
- };
297
- }
298
222
 
299
223
  // temperature padrão 0.2 (RFC-002 §5): "Determinism over Creativity". Pode ser
300
224
  // sobrescrita por chamada via opts, mas o default cobre spec/plan/decompose.
@@ -333,11 +257,14 @@ export async function generateDocument(systemPrompt, userContent, opts = {}) {
333
257
  const callOpts = {
334
258
  temperature: sendTemperature ? temperature : undefined,
335
259
  maxTokens,
260
+ action: opts.action,
261
+ // É aqui que o motor paga por si: o modelo passa a poder LER o
262
+ // repositório antes de escrever o documento. É o que os blocos
263
+ // `requires-tools` dos model-prompt sempre descreveram.
264
+ tools: opts.tools ?? ['Read', 'Glob', 'Grep'],
336
265
  };
337
266
  const { text, usage } = await withRetry(`Geração via ${ai.provider}`, () =>
338
- ai.provider === 'openrouter'
339
- ? generateWithOpenRouter(system, userContent, ai, callOpts)
340
- : generateWithAnthropic(system, userContent, ai, callOpts));
267
+ generateViaEngine(system, userContent, ai, callOpts));
341
268
  inputTokens += usage.inputTokens;
342
269
  outputTokens += usage.outputTokens;
343
270
  if (typeof usage.cost === 'number') cost = (cost ?? 0) + usage.cost;
@@ -441,14 +368,13 @@ export async function generateStructured(systemPrompt, userContent, opts = {}) {
441
368
  maxTokens,
442
369
  schema,
443
370
  strict,
371
+ action: opts.action,
444
372
  };
445
373
 
446
374
  // A validação roda DENTRO do retry de propósito: um payload fora do schema é
447
375
  // marcado como transitório, e repetir a mesma requisição costuma resolver.
448
376
  const { value, usage } = await withRetry(`Geração estruturada via ${ai.provider}`, async () => {
449
- const res = ai.provider === 'openrouter'
450
- ? await generateWithOpenRouter(systemPrompt, userContent, ai, callOpts)
451
- : await generateWithAnthropic(systemPrompt, userContent, ai, callOpts);
377
+ const res = await generateViaEngine(systemPrompt, userContent, ai, callOpts);
452
378
  return { value: schema.validate ? schema.validate(res.json) : res.json, usage: res.usage };
453
379
  }, opts.retry);
454
380
 
@@ -506,181 +432,72 @@ export function extractOpenRouterUsage(usage) {
506
432
  };
507
433
  }
508
434
 
509
- async function generateWithAnthropic(
510
- systemPrompt, userContent, ai, { temperature, maxTokens, schema, strict }
435
+ /**
436
+ * Ponte para o motor do agente (`src/agent/`).
437
+ *
438
+ * Substitui as duas funções que falavam com a API diretamente
439
+ * (`generateWithAnthropic` / `generateWithOpenRouter`). O motor foi portado do
440
+ * agent-cli e traz o que o caminho direto não tinha: loop agêntico com
441
+ * Read/Glob/Grep de verdade e instrumentação Langfuse.
442
+ *
443
+ * Tudo o que já estava CERTO neste módulo continua acima desta linha —
444
+ * resolução de modelo por ação/label, retry, lint de idioma, contagem de uso.
445
+ * O motor cuida da chamada; este arquivo segue dono da política.
446
+ *
447
+ * O prompt do sistema vira `systemPromptAppend` de propósito: no backend
448
+ * anthropic ele é ANEXADO ao preset `claude_code`, preservando a competência de
449
+ * uso de tools do harness em vez de substituí-la.
450
+ *
451
+ * @returns {Promise<{text: string, json?: object, usage: object}>}
452
+ */
453
+ async function generateViaEngine(
454
+ systemPrompt, userContent, ai, { temperature, maxTokens, schema, strict, action, tools },
511
455
  ) {
512
- const apiKey = process.env.ANTHROPIC_API_KEY;
513
- if (!apiKey) {
514
- throw new Error(
515
- 'ANTHROPIC_API_KEY not set.\n' +
516
- 'Add it as a GitHub Actions secret or set it in your environment.'
517
- );
518
- }
456
+ const result = await runAgent(userContent, {
457
+ provider: ai.provider,
458
+ model: ai.model,
459
+ systemPromptAppend: systemPrompt,
460
+ // Sessão/usuário alimentam o agrupamento das traces no Langfuse. Sem
461
+ // telemetria configurada nada disso sai do processo.
462
+ sessionId: `spec-wave-${action || 'run'}`,
463
+ userId: 'spec-wave',
464
+ ...(tools ? { tools } : {}),
465
+ ...(maxTokens ? { maxTokens } : {}),
466
+ ...(temperature !== undefined ? { temperature } : {}),
467
+ ...(schema
468
+ ? { responseSchema: { ...schema, strict: strict === true } }
469
+ : {}),
470
+ ...(action ? { action, extraTags: [action] } : {}),
471
+ cwd: ai.cwd ?? process.cwd(),
472
+ quiet: true,
473
+ });
519
474
 
520
- const client = new Anthropic({ apiKey });
521
- const message = await client.messages.create(buildAnthropicRequest({
522
- ai, systemPrompt, userContent, temperature, maxTokens, schema, strict,
523
- }));
524
-
525
- // A resposta nem sempre começa com um bloco de texto (recusa, resposta vazia):
526
- // `content[0].text` cru virava TypeError com mensagem inútil.
527
- const text = (message.content || [])
528
- .filter((block) => block.type === 'text')
529
- .map((block) => block.text)
530
- .join('');
531
- // Com tool_choice forçado a resposta traz um bloco tool_use e NENHUM bloco de
532
- // texto — daí a necessidade do branch dedicado antes da checagem de texto vazio.
533
- const tool = (message.content || [])
534
- .find((block) => block.type === 'tool_use' && block.name === schema?.name);
535
-
536
- if (message.stop_reason === 'refusal') {
537
- throw new Error(
538
- `A Anthropic recusou a requisição (stop_reason=refusal` +
539
- `${message.stop_details?.category ? `, categoria=${message.stop_details.category}` : ''}). ` +
540
- 'Revise o conteúdo da issue de origem.'
541
- );
542
- }
543
- if (message.stop_reason === 'model_context_window_exceeded') {
544
- // Estouro na ENTRADA — subir max_tokens não resolve; o que precisa encolher
545
- // é a issue/contexto enviado.
546
- throw new Error(
547
- `Contexto de entrada excedido (${ai.model}). Reduza o tamanho da issue de origem ` +
548
- 'ou do tech_context antes de repetir.'
549
- );
550
- }
551
- if (isTruncationReason(message.stop_reason)) {
552
- throw new TruncatedOutputError({
553
- provider: 'anthropic',
554
- model: ai.model,
555
- maxTokens,
556
- reason: message.stop_reason,
557
- // Sob tool call forçado não há bloco de texto: medir `text.length` diria
558
- // sempre "~0 caracteres antes do corte".
559
- chars: schema ? JSON.stringify(tool?.input ?? '').length : text.length,
560
- });
561
- }
475
+ const usage = {
476
+ inputTokens: result.usage?.inputTokens ?? 0,
477
+ outputTokens: result.usage?.outputTokens ?? 0,
478
+ cost: result.costUsd,
479
+ };
562
480
 
563
- // ATENÇÃO: este branch precisa vir ANTES da checagem de `!text` — uma resposta
564
- // com tool call forçado não tem bloco de texto, e cair no `!text` faria toda
565
- // crítica queimar os 3 retries com a mensagem errada.
566
481
  if (schema) {
567
- if (!tool || typeof tool.input !== 'object' || tool.input === null) {
482
+ if (result.structured === null || result.structured === undefined) {
568
483
  const err = new Error(
569
- `A Anthropic não devolveu a ferramenta \`${schema.name}\` ` +
570
- `(stop_reason=${message.stop_reason}). Conteúdo: ${text.slice(0, 200) || '(vazio)'}`
484
+ `O modelo ${ai.model} não devolveu a saída estruturada "${schema.name}" ` +
485
+ `(subtype=${result.resultSubtype}).`
571
486
  );
572
- err.transient = true; // ferramenta forçada: repetir a requisição costuma resolver
487
+ err.transient = true; // repetir a mesma requisição costuma resolver
573
488
  throw err;
574
489
  }
575
- return {
576
- text: JSON.stringify(tool.input),
577
- json: tool.input,
578
- usage: extractAnthropicUsage(message.usage),
579
- };
490
+ return { text: result.outputText, json: result.structured, usage };
580
491
  }
581
492
 
493
+ const text = stripReasoning(result.outputText || '');
582
494
  if (!text) {
583
495
  const err = new Error(
584
- `A Anthropic retornou resposta sem texto (stop_reason=${message.stop_reason}).`
585
- );
586
- err.transient = true;
587
- throw err;
588
- }
589
-
590
- return { text, usage: extractAnthropicUsage(message.usage) };
591
- }
592
-
593
- async function generateWithOpenRouter(
594
- systemPrompt, userContent, ai, { temperature, maxTokens, schema }
595
- ) {
596
- const apiKey = process.env.OPENROUTER_API_KEY;
597
- if (!apiKey) {
598
- throw new Error(
599
- 'OPENROUTER_API_KEY not set.\n' +
600
- 'Add it as a GitHub Actions secret or set it in your environment.'
601
- );
602
- }
603
-
604
- const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
605
- method: 'POST',
606
- headers: {
607
- Authorization: `Bearer ${apiKey}`,
608
- 'Content-Type': 'application/json',
609
- 'HTTP-Referer': 'https://github.com/moacsjr/spec-wave',
610
- 'X-Title': 'spec-wave',
611
- },
612
- body: JSON.stringify(buildOpenRouterBody({
613
- ai, systemPrompt, userContent, temperature, maxTokens, schema,
614
- })),
615
- });
616
-
617
- if (!res.ok) {
618
- const body = await res.text();
619
- const err = new Error(
620
- `OpenRouter API ${res.status}: ${body}` +
621
- // require_parameters faz o roteador recusar quando nenhum provedor do modelo
622
- // implementa response_format — erro de CONFIGURAÇÃO, não de rede.
623
- (schema && (res.status === 404 || res.status === 400)
624
- ? `\nNenhum provedor de ${ai.model} suporta response_format/json_schema. ` +
625
- 'Aponte `ai.models.critique` no .spec-wave.json para um modelo com saída estruturada.'
626
- : '')
627
- );
628
- err.status = res.status;
629
- throw err;
630
- }
631
-
632
- // Um 200 com corpo vazio ou cortado acontece em gerações longas. `res.json()`
633
- // cru lançaria "Unexpected end of JSON input" — mensagem que não diz nada a
634
- // quem está olhando o board. Lê como texto, reporta o que veio e marca como
635
- // transitória para o retry pegar.
636
- const raw = await res.text();
637
- let data;
638
- try {
639
- data = JSON.parse(raw);
640
- } catch {
641
- const err = new Error(
642
- `OpenRouter devolveu ${res.status} com corpo inválido (${raw.length} bytes): ` +
643
- `${raw.slice(0, 200) || '(vazio)'}`
496
+ `O provider ${ai.provider} retornou resposta sem texto ` +
497
+ `(subtype=${result.resultSubtype}, turnos=${result.numTurns}).`
644
498
  );
645
499
  err.transient = true;
646
500
  throw err;
647
501
  }
648
-
649
- const choice = data?.choices?.[0];
650
- const content = stripReasoning(choice?.message?.content || '');
651
-
652
- // finish_reason='length' = cortado no teto. Checado ANTES do conteúdo vazio:
653
- // um corte durante o raciocínio devolve texto vazio, e "resposta vazia" (que
654
- // é tratada como transitória) esconderia a causa real por trás de 3 retries.
655
- if (isTruncationReason(choice?.finish_reason)) {
656
- throw new TruncatedOutputError({
657
- provider: 'openrouter',
658
- model: ai.model,
659
- maxTokens,
660
- // native_finish_reason preserva o motivo cru do provedor upstream.
661
- reason: `${choice.finish_reason}${choice.native_finish_reason ? `/${choice.native_finish_reason}` : ''}`,
662
- chars: content.length,
663
- });
664
- }
665
- if (!content) {
666
- const err = new Error(`OpenRouter retornou resposta vazia: ${JSON.stringify(data)}`);
667
- err.transient = true;
668
- throw err;
669
- }
670
-
671
- if (schema) {
672
- const raw = stripOuterFence(content);
673
- try {
674
- return { text: raw, json: JSON.parse(raw), usage: extractOpenRouterUsage(data.usage) };
675
- } catch {
676
- const err = new Error(
677
- `O modelo ${ai.model} ignorou response_format e devolveu texto não-JSON ` +
678
- `(${raw.length} bytes): ${raw.slice(0, 200)}`
679
- );
680
- err.transient = true; // ≠ truncamento: repetir pode dar certo
681
- throw err;
682
- }
683
- }
684
-
685
- return { text: content, usage: extractOpenRouterUsage(data.usage) };
502
+ return { text, usage };
686
503
  }
@@ -22,6 +22,7 @@ import { generateStructured } from './claude.mjs';
22
22
  import {
23
23
  LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN, DEFAULT_MAX_CRITIQUE_ATTEMPTS, labelNames,
24
24
  } from '../config.mjs';
25
+ import { loadPrompt, toolFreeSystemPrompt } from './prompt-loader.mjs';
25
26
 
26
27
  // Severidades aceitas — enum FECHADO. O teste de prefixo /^grave/i sobre texto
27
28
  // livre que existia aqui aceitava "gravíssimo" e rebaixava "critical"/"high"
@@ -41,21 +42,11 @@ const MAX_FINDING_CHARS = 800;
41
42
  export const CRITIQUE_TOOL_NAME = 'registrar_findings';
42
43
 
43
44
  // Rótulo do artefato auditado, por contexto — usado no cabeçalho do comentário.
44
- const KIND_LABEL = { plan: 'plan.md', stories: 'decomposition.md' };
45
-
46
- // Redação específica por tipo de auditoria. 'plan' audita o plan.md contra a
47
- // spec; 'stories' audita a decomposição proposta contra spec + plan.
48
- const KIND_FOCUS = {
49
- plan:
50
- 'Audite o plan.md contra o spec.md, as regras de negócio e o tech_context fornecidos. ' +
51
- 'Procure decisões técnicas que contradizem ou ignoram requisitos da spec e ' +
52
- 'tecnologias/serviços fora do tech_context.',
53
- stories:
54
- 'Audite a decomposição proposta (documento Markdown com Stories e suas Tasks) contra o ' +
55
- 'spec.md e o plan.md fornecidos. Procure Stories que contradizem, invertem ou ignoram ' +
56
- 'requisitos da spec ou decisões do plan, critérios de aceite incompatíveis com as regras ' +
57
- 'de negócio, e Tasks que não sustentam a Story a que pertencem.',
58
- };
45
+ const KIND_LABEL = { plan: 'plan.md', stories: 'decomposition.md', bug: 'bug.md' };
46
+
47
+ // Prompt por tipo de auditoria. 'plan' audita o plan.md contra a spec;
48
+ // 'stories' audita a decomposição proposta contra spec + plan.
49
+ const KIND_PROMPT = { plan: 'plan/critique', stories: 'decompose/critique', bug: 'bug/critique' };
59
50
 
60
51
  // A decomposição virou arquivo revisável (decomposition.md): um finding só é
61
52
  // acionável se disser ONDE está o problema. Os títulos "## Story N" e
@@ -67,28 +58,39 @@ const ANCHOR_RULE = `Cada finding DEVE citar, no campo "anchor", o trecho audita
67
58
  - "geral" quando o problema for da decomposição como um todo (ex.: requisito da spec que nenhuma Story cobre).
68
59
  Use EXATAMENTE os números que aparecem nos títulos "## Story N — ..." e "### Task N.M — ..." do documento.`;
69
60
 
70
- function buildSystemPrompt(kind) {
71
- const focus = KIND_FOCUS[kind] || KIND_FOCUS.plan;
61
+ /**
62
+ * Monta o system prompt da crítica: corpo editável + contrato de máquina.
63
+ *
64
+ * A divisão não é estética. O CORPO (`model-prompt.critique.md` da skill do comando) é a redação
65
+ * — foco da auditoria, o que conta como achado, a barra de rigor — e o time
66
+ * pode sobrescrevê-lo em `.spec-wave/prompts/` para apertar ou afrouxar o
67
+ * critério. O CONTRATO abaixo é acoplado a `critiqueJsonSchema()` e a
68
+ * `validateCritiquePayload()`: o enum de `severity`, a âncora `Story N` /
69
+ * `Task N.M` e a obrigação de chamar a tool. Ele é anexado por ÚLTIMO
70
+ * justamente para que um override de projeto não consiga anulá-lo — um payload
71
+ * fora do contrato falha alto, e não vale deixar isso na mão do texto.
72
+ *
73
+ * @param {'plan'|'stories'} kind
74
+ * @param {string} [cwd] raiz do repositório (resolve o override do projeto)
75
+ */
76
+ function buildSystemPrompt(kind, cwd) {
77
+ const prompt = loadPrompt(KIND_PROMPT[kind] || KIND_PROMPT.plan, ...(cwd ? [{ cwd }] : []));
72
78
  const anchor = kind === 'stories' ? `\n\n${ANCHOR_RULE}` : '';
73
- return `Você é um revisor técnico CÉTICO e adversarial. Seu papel é encontrar problemas, não elogiar.
74
79
 
75
- ${focus}
76
-
77
- Liste:
78
- - contradições diretas entre os documentos;
79
- - inversões de requisito (ex.: consentimento→persistência invertidos: a spec exige consentimento ANTES de persistir e o documento persiste antes de pedir consentimento);
80
- - violações de restrições explícitas (ex.: minimização de dados LGPD, limites de retenção, campos proibidos);
81
- - itens que contradizem ou ignoram a spec.
80
+ const contract = `## Contrato de saída
82
81
 
83
82
  Classifique cada finding no campo "severity", usando EXATAMENTE um destes dois valores:
84
83
  - "grave": contradiz um requisito ou regra explícita — causaria implementação errada;
85
84
  - "menor": inconsistência, omissão ou ambiguidade que merece atenção mas não inverte requisito.
86
85
 
87
- NÃO invente problemas: se os documentos estiverem consistentes, retorne a lista vazia.
88
86
  Escreva os findings em português (pt-BR), em uma frase objetiva cada.${anchor}
89
87
 
90
88
  Registre o resultado chamando a ferramenta \`${CRITIQUE_TOOL_NAME}\`. Nunca responda em texto livre.
91
89
  Se os documentos estiverem consistentes, chame-a com "findings": [].`;
90
+
91
+ // Sem tool loop de leitura aqui: `generateStructured` faz UMA chamada, com a
92
+ // tool de saída estruturada como única ferramenta.
93
+ return toolFreeSystemPrompt(prompt, contract);
92
94
  }
93
95
 
94
96
  function critiqueJsonSchema(kind) {
@@ -338,6 +340,11 @@ const KIND_TRAILER = {
338
340
  `\`decomposition.md\` (as âncoras acima apontam para ele), remova a label ` +
339
341
  `\`${LABEL_CRITIQUE_FAILED}\` e reaplique \`spec-wave:decompose\` para uma nova crítica.`
340
342
  : '_Findings menores não bloqueiam a decomposição._'),
343
+ bug: (graves) => (graves
344
+ ? `⛔ Há findings **graves**: a label \`${LABEL_CRITIQUE_FAILED}\` bloqueia a triagem ` +
345
+ 'até ser removida. Um bug com causa raiz errada produz correção errada — corrija o ' +
346
+ '`bug.md` e reaplique `spec-wave:bug`.'
347
+ : '_Findings menores não bloqueiam a triagem._'),
341
348
  };
342
349
 
343
350
  /**
@@ -406,11 +413,13 @@ export function renderCritiqueMarkdown({
406
413
  * chamador decidir entre seguir com aviso e abortar.
407
414
  *
408
415
  * @param {object} params
409
- * @param {'plan'|'stories'} params.kind o que está sendo auditado
416
+ * @param {'plan'|'stories'|'bug'} params.kind o que está sendo auditado
410
417
  * @param {string} [params.spec] conteúdo do spec.md
411
418
  * @param {string} [params.plan] conteúdo do plan.md
412
419
  * @param {string} [params.techContextYaml] tech_context serializado em YAML
413
420
  * @param {string} [params.decomposition] conteúdo do decomposition.md
421
+ * @param {string} [params.bugDoc] conteúdo do bug.md
422
+ * @param {string} [params.bugReport] relato original (corpo da issue + comentários)
414
423
  * @param {number} [params.attempt] tentativa em curso (cabeçalho e marcador)
415
424
  * @param {number} [params.maxAttempts]
416
425
  * @param {string} [params.model] modelo imposto (escalada); undefined = cadeia normal
@@ -419,9 +428,9 @@ export function renderCritiqueMarkdown({
419
428
  * @returns {Promise<{grave, findings, markdown, attempt, model}>}
420
429
  */
421
430
  export async function runCritique({
422
- kind, spec, plan, techContextYaml, decomposition,
431
+ kind, spec, plan, techContextYaml, decomposition, bugDoc, bugReport,
423
432
  attempt = 1, maxAttempts = DEFAULT_MAX_CRITIQUE_ATTEMPTS,
424
- model, labels = [], usage,
433
+ model, labels = [], usage, cwd,
425
434
  } = {}) {
426
435
  const sections = [];
427
436
  if (spec) sections.push(`## spec.md\n\n${spec}`);
@@ -434,9 +443,13 @@ export async function runCritique({
434
443
  `## Decomposição proposta (decomposition.md)\n\n\`\`\`\`markdown\n${decomposition}\n\`\`\`\``
435
444
  );
436
445
  }
446
+ // O relato é a fonte contra a qual o bug.md é auditado: a causa raiz proposta
447
+ // tem que explicar OS SINTOMAS RELATADOS, não sintomas plausíveis quaisquer.
448
+ if (bugReport) sections.push(`## Relato original (issue e comentários)\n\n${bugReport}`);
449
+ if (bugDoc) sections.push(`## bug.md\n\n${bugDoc}`);
437
450
  const userContent = sections.join('\n\n') || '(nenhum documento fornecido)';
438
451
 
439
- const report = await generateStructured(buildSystemPrompt(kind), userContent, {
452
+ const report = await generateStructured(buildSystemPrompt(kind, cwd), userContent, {
440
453
  action: 'critique',
441
454
  temperature: 0,
442
455
  schema: {
@@ -0,0 +1,145 @@
1
+ // Modo de execução do fluxo: GitHub Actions ou sessão local.
2
+ //
3
+ // `generate-spec`, `generate-plan` e `decompose` nasceram como comandos de
4
+ // Action e exigiam `GITHUB_REPOSITORY`, recusando qualquer execução fora do
5
+ // runner. Mas eles são só comandos — o que os prendia ao CI era a resolução de
6
+ // owner/repo, não o trabalho em si. Agora o MESMO comando roda nos dois lugares:
7
+ // dentro do Action, disparado por label, ou na sua sessão do agente.
8
+ //
9
+ // O modo é detectado pelo ambiente (`GITHUB_ACTIONS`), não por flag: um
10
+ // comando com dois nomes para a mesma coisa envelhece mal.
11
+ //
12
+ // **O comportamento é deliberadamente IDÊNTICO nos dois modos** — gera, commita,
13
+ // dá pull --rebase, faz push, comenta na issue e avança a Etapa. O board é a
14
+ // fonte de verdade do RFC-001 independentemente de onde a geração rodou; um modo
15
+ // local que não sincronizasse o board deixaria o próximo passo do fluxo cego.
16
+ //
17
+ // Duas diferenças existem, e as duas são de SEGURANÇA, não de resultado:
18
+ //
19
+ // 1. IDENTIDADE DO GIT. O Action roda `git config user.email "spec-wave[bot]"`
20
+ // sem `--global`, o que grava em `.git/config`. Num runner descartável isso
21
+ // é inócuo; no seu clone, mudaria o autor de TODOS os seus commits futuros
22
+ // naquele repositório. Localmente a sua identidade é preservada.
23
+ // 2. FALHA DE PUSH. No Action, não conseguir publicar é falha do job. Local, o
24
+ // arquivo já está gerado e commitado — perder isso porque o remoto andou
25
+ // seria pior que avisar e deixar você resolver o push.
26
+
27
+ import { execSync } from 'node:child_process';
28
+ import { mkdirSync, writeFileSync } from 'node:fs';
29
+ import path from 'node:path';
30
+ import { resolveRepoContext } from './project-root.mjs';
31
+ import { CONFIG_FILE } from '../config.mjs';
32
+
33
+ /** Rodando dentro do GitHub Actions? (função PURA) */
34
+ export function isActionsRun(env = process.env) {
35
+ return env.GITHUB_ACTIONS === 'true';
36
+ }
37
+
38
+ /** 'actions' | 'local' (função PURA) */
39
+ export function executionMode(env = process.env) {
40
+ return isActionsRun(env) ? 'actions' : 'local';
41
+ }
42
+
43
+ /**
44
+ * Resolve owner/repo + modo, ou lança com a instrução certa para cada contexto.
45
+ *
46
+ * Nos Actions o `GITHUB_REPOSITORY` vem do runner; local, o `.spec-wave.json`
47
+ * gravado pelo `init` é a fonte. `resolveRepoContext` já cobre os dois.
48
+ *
49
+ * @param {object} [opts]
50
+ * @param {string} [opts.cwd]
51
+ * @param {string} [opts.command] nome do comando, para a mensagem de erro
52
+ * @param {object} [opts.env]
53
+ * @returns {{owner: string, repo: string, root: string|null, config: object|null, mode: 'actions'|'local'}}
54
+ */
55
+ export function resolveFlowContext({ cwd = process.cwd(), command = 'este comando', env = process.env } = {}) {
56
+ // Repassa o `env` recebido: sem isso o modo local fica intestável dentro do
57
+ // GitHub Actions, que define GITHUB_REPOSITORY em toda execução.
58
+ const { owner, repo, root, config } = resolveRepoContext(cwd, env);
59
+ const mode = executionMode(env);
60
+
61
+ if (!owner || !repo) {
62
+ throw new Error(
63
+ 'Não foi possível determinar owner/repo.\n' +
64
+ (mode === 'actions'
65
+ ? 'No GitHub Actions, o runner define GITHUB_REPOSITORY — verifique o workflow.'
66
+ : `Rode dentro de um repositório com ${CONFIG_FILE} (\`spec-wave init\`), ` +
67
+ `ou defina GITHUB_REPOSITORY=owner/repo:\n` +
68
+ ` GITHUB_REPOSITORY=owner/repo spec-wave ${command} --issue-number 1`)
69
+ );
70
+ }
71
+ return { owner, repo, root, config, mode };
72
+ }
73
+
74
+ /**
75
+ * Grava um arquivo gerado e o publica: commit + pull --rebase + push.
76
+ *
77
+ * Era o mesmo bloco copiado em `generate-spec`, `generate-plan` e `decompose`,
78
+ * com a identidade do bot embutida. Centralizado aqui para que a diferença
79
+ * entre os modos exista num lugar só.
80
+ *
81
+ * O commit é escopado ao caminho (`git commit -- <arquivo>`): sem isso, qualquer
82
+ * coisa que você já tivesse no index entraria junto no commit do spec-wave —
83
+ * irrelevante num runner limpo, nada irrelevante no seu clone.
84
+ *
85
+ * @param {object} params
86
+ * @param {string} params.filePath caminho absoluto do arquivo
87
+ * @param {string} params.content
88
+ * @param {string} params.message mensagem de commit
89
+ * @param {'actions'|'local'} params.mode
90
+ * @returns {{committed: boolean, pushed: boolean, warning: string|null}}
91
+ */
92
+ export function commitGenerated({ filePath, content, message, mode }) {
93
+ mkdirSync(path.dirname(filePath), { recursive: true });
94
+ writeFileSync(filePath, content, 'utf-8');
95
+
96
+ const git = (cmd, opts = {}) => execSync(cmd, { stdio: 'inherit', ...opts });
97
+ const gitQuiet = (cmd) => execSync(cmd, { stdio: 'pipe' }).toString().trim();
98
+
99
+ if (mode === 'actions') {
100
+ // Runner descartável: identidade do bot é o que se quer no histórico.
101
+ git('git config user.email "spec-wave[bot]@github.com"');
102
+ git('git config user.name "spec-wave[bot]"');
103
+ }
104
+
105
+ git(`git add "${filePath}"`);
106
+
107
+ // Nada mudou (regerar conteúdo idêntico) → `git commit` sairia 1 e derrubaria
108
+ // o comando depois de o trabalho estar feito.
109
+ let hasChanges = true;
110
+ try {
111
+ execSync(`git diff --cached --quiet -- "${filePath}"`, { stdio: 'pipe' });
112
+ hasChanges = false;
113
+ } catch {
114
+ hasChanges = true;
115
+ }
116
+ if (!hasChanges) {
117
+ return { committed: false, pushed: false, warning: 'conteúdo idêntico ao já versionado — nada a commitar' };
118
+ }
119
+
120
+ git(`git commit -m "${message}" -- "${filePath}"`);
121
+
122
+ try {
123
+ git('git pull --rebase');
124
+ git('git push');
125
+ return { committed: true, pushed: true, warning: null };
126
+ } catch (err) {
127
+ if (mode === 'actions') throw err;
128
+ // Local: o arquivo está gerado e commitado. Derrubar o comando aqui
129
+ // esconderia esse fato atrás de um erro de rede/divergência.
130
+ const branch = (() => {
131
+ try {
132
+ return gitQuiet('git rev-parse --abbrev-ref HEAD');
133
+ } catch {
134
+ return 'seu branch';
135
+ }
136
+ })();
137
+ return {
138
+ committed: true,
139
+ pushed: false,
140
+ warning:
141
+ `commit feito em ${branch}, mas o push falhou (${err.message.split('\n')[0]}). ` +
142
+ 'O arquivo está salvo e versionado — publique quando resolver.',
143
+ };
144
+ }
145
+ }