@spec-wave/cli 0.13.0 → 0.14.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.
@@ -20,6 +20,7 @@ import { homedir, tmpdir } from 'node:os';
20
20
  import path from 'node:path';
21
21
  import { fileURLToPath } from 'node:url';
22
22
  import { CONFIG_FILE } from '../config.mjs';
23
+ import { findConfigPath } from '../lib/project-root.mjs';
23
24
 
24
25
  const __dir = path.dirname(fileURLToPath(import.meta.url));
25
26
  const TEMPLATES_DIR = path.join(__dir, '..', 'templates', 'agent');
@@ -180,8 +181,8 @@ function writeInstalledTag(home, tag) {
180
181
  }
181
182
 
182
183
  function readRepoConfig(cwd) {
183
- const configPath = path.join(cwd, CONFIG_FILE);
184
- if (!existsSync(configPath)) {
184
+ const configPath = findConfigPath(cwd);
185
+ if (!configPath) {
185
186
  return { error: `${CONFIG_FILE} não encontrado. Rode \`spec-wave init\` neste repositório primeiro.` };
186
187
  }
187
188
  try {
@@ -10,8 +10,15 @@ import chalk from 'chalk';
10
10
  import { Octokit } from '@octokit/rest';
11
11
  import { resolveToken, verifyTokenScopes } from '../api/auth.mjs';
12
12
  import { getProjectSnapshot } from '../api/github-graphql.mjs';
13
- import { CONFIG_FILE, WORKFLOW_FILES, getProvider, DEFAULT_PROVIDER } from '../config.mjs';
14
- import { DEFAULT_MAX_TOKENS } from '../lib/claude.mjs';
13
+ import {
14
+ CONFIG_FILE, WORKFLOW_FILES, getProvider, DEFAULT_PROVIDER, STATUS_OPTIONS,
15
+ ALL_LABELS, LABEL_NEEDS_HUMAN, MODEL_LABEL_PREFIX, DEFAULT_MAX_CRITIQUE_ATTEMPTS,
16
+ } from '../config.mjs';
17
+ import { findConfigPath } from '../lib/project-root.mjs';
18
+ import {
19
+ DEFAULT_MAX_TOKENS, MAX_TOKENS_BY_ACTION, supportsStrictSchema,
20
+ } from '../lib/claude.mjs';
21
+ import { CLI_VERSION } from '../lib/templates.mjs';
15
22
 
16
23
  // Mesmo padrão de instanciação de github-rest.mjs, mas com o logger mudo:
17
24
  // aqui 404/403 são resultados esperados dos checks, não erros a logar.
@@ -199,11 +206,13 @@ async function checkGhAccount(ctx) {
199
206
 
200
207
  async function checkConfig(ctx) {
201
208
  const name = `Configuração (${CONFIG_FILE})`;
202
- if (!existsSync(ctx.configPath)) {
209
+ if (!ctx.configPath) {
203
210
  return {
204
211
  name,
205
212
  status: 'fail',
206
- detail: `${CONFIG_FILE} não encontrado em ${ctx.cwd}. Rode \`npx @spec-wave/cli@latest init\`.`,
213
+ detail:
214
+ `${CONFIG_FILE} não encontrado em ${ctx.cwd} nem em nenhum diretório acima. ` +
215
+ 'Rode `npx @spec-wave/cli@latest init`.',
207
216
  };
208
217
  }
209
218
  if (ctx.cfgError) {
@@ -211,6 +220,9 @@ async function checkConfig(ctx) {
211
220
  }
212
221
  const { cfg } = ctx;
213
222
  const notes = [`Repositório: ${cfg.owner ?? '?'}/${cfg.repo ?? '?'}.`];
223
+ if (ctx.root && ctx.root !== ctx.cwd) {
224
+ notes.push(`Raiz do projeto: ${ctx.root} (o comando foi rodado de um subdiretório).`);
225
+ }
214
226
 
215
227
  const fields = cfg.project?.fields || {};
216
228
  const missingFields = ['Etapa', 'Status'].filter((f) => !fields[f]);
@@ -254,6 +266,109 @@ async function checkConfig(ctx) {
254
266
  return { name, status: 'ok', detail: notes.join('\n') };
255
267
  }
256
268
 
269
+ /**
270
+ * Higiene do board e das labels (função PURA — testável sem rede).
271
+ *
272
+ * Dois problemas que passavam em silêncio:
273
+ * • colunas de Etapa fora de STATUS_OPTIONS. A checagem de retrocesso compara a
274
+ * etapa atual com a ordem canônica; um item numa coluna desconhecida não tem
275
+ * posição na ordem, e o board perde a garantia de fluxo só-para-frente;
276
+ * • labels `spec-wave:*` descontinuadas. O `update` só criava e atualizava, então
277
+ * uma label removida do config ficava para sempre (caso da `spec-wave:force`).
278
+ *
279
+ * @param {object} params
280
+ * @param {string[]} [params.boardStages] opções reais de Etapa no project
281
+ * @param {string[]} [params.repoLabels] nomes das labels do repo
282
+ * @returns {{ unknownStages: string[], missingStages: string[], orphanLabels: string[], missingLabels: string[] }}
283
+ */
284
+ export function inspectBoardHygiene({ boardStages = null, repoLabels = null } = {}) {
285
+ const canonical = STATUS_OPTIONS.map(s => s.name);
286
+ const known = new Set(canonical);
287
+ const unknownStages = boardStages ? boardStages.filter(s => !known.has(s)) : [];
288
+ const missingStages = boardStages ? canonical.filter(s => !boardStages.includes(s)) : [];
289
+
290
+ const knownLabels = new Set(ALL_LABELS.map(l => l.name));
291
+ const orphanLabels = repoLabels
292
+ ? repoLabels.filter(n => n.startsWith('spec-wave:') && !knownLabels.has(n))
293
+ : [];
294
+ const missingLabels = repoLabels
295
+ ? ALL_LABELS.map(l => l.name).filter(n => !repoLabels.includes(n))
296
+ : [];
297
+ return { unknownStages, missingStages, orphanLabels, missingLabels };
298
+ }
299
+
300
+ async function checkBoardHygiene(ctx) {
301
+ const name = 'Higiene do board e das labels';
302
+ const { cfg } = ctx;
303
+ if (!cfg?.project?.id) {
304
+ return { name, status: 'warn', detail: 'Sem project no config — verificação pulada.' };
305
+ }
306
+
307
+ let boardStages = null;
308
+ const snapshot = ctx.projectSnapshot;
309
+ if (snapshot?.fields?.['Etapa']?.options) {
310
+ boardStages = Object.keys(snapshot.fields['Etapa'].options);
311
+ } else if (cfg.project.fields?.['Etapa']?.options) {
312
+ // Sem snapshot (sem token/rede), o config é a melhor aproximação — foi nele
313
+ // que a coluna extra "📋 Backlog Técnico" ficou pendurada.
314
+ boardStages = Object.keys(cfg.project.fields['Etapa'].options);
315
+ }
316
+
317
+ let repoLabels = null;
318
+ if (ctx.token && cfg.owner && cfg.repo) {
319
+ try {
320
+ const res = await makeOctokit(ctx.token)
321
+ .paginate('GET /repos/{owner}/{repo}/labels', { owner: cfg.owner, repo: cfg.repo, per_page: 100 });
322
+ repoLabels = res.map(l => l.name);
323
+ } catch {
324
+ // labels não verificáveis agora — segue só com o board
325
+ }
326
+ }
327
+
328
+ const { unknownStages, missingStages, orphanLabels, missingLabels } =
329
+ inspectBoardHygiene({ boardStages, repoLabels });
330
+
331
+ const notes = [];
332
+ let status = 'ok';
333
+ if (boardStages) {
334
+ notes.push(`Etapas no board: ${boardStages.length}/${STATUS_OPTIONS.length} do RFC-001.`);
335
+ } else {
336
+ notes.push('Opções de Etapa não legíveis — verificação de colunas pulada.');
337
+ }
338
+ if (unknownStages.length > 0) {
339
+ status = 'warn';
340
+ notes.push(
341
+ `Colunas fora do fluxo canônico: ${unknownStages.join(', ')}. Um item nelas não tem ` +
342
+ 'posição na ordem das etapas, e a garantia de "a Etapa nunca retrocede" não se aplica. ' +
343
+ 'Mova os itens para uma etapa do fluxo e remova a coluna.'
344
+ );
345
+ }
346
+ if (missingStages.length > 0) {
347
+ status = 'warn';
348
+ notes.push(`Etapas do RFC-001 ausentes no board: ${missingStages.join(', ')} — rode \`refresh\`.`);
349
+ }
350
+ if (repoLabels) {
351
+ if (orphanLabels.length > 0) {
352
+ status = 'warn';
353
+ notes.push(
354
+ `Labels \`spec-wave:*\` descontinuadas ainda no repo: ${orphanLabels.join(', ')}. ` +
355
+ 'Nenhum comando as honra — rode `npx @spec-wave/cli@latest update` para removê-las.'
356
+ );
357
+ }
358
+ if (missingLabels.length > 0) {
359
+ status = 'warn';
360
+ notes.push(`Labels do fluxo ausentes no repo: ${missingLabels.join(', ')} — rode \`update\`.`);
361
+ }
362
+ if (orphanLabels.length === 0 && missingLabels.length === 0) {
363
+ notes.push(`Labels: ${ALL_LABELS.length} do fluxo presentes, nenhuma descontinuada.`);
364
+ }
365
+ } else {
366
+ notes.push('Labels do repo não verificáveis agora (sem token ou sem acesso).');
367
+ }
368
+
369
+ return { name, status, detail: notes.join('\n') };
370
+ }
371
+
257
372
  async function checkRepoAccess(ctx) {
258
373
  const name = 'Acesso ao repositório';
259
374
  const { cfg } = ctx;
@@ -307,9 +422,92 @@ async function checkAi(ctx) {
307
422
  const byAction = fileAi.maxTokensByAction && Object.keys(fileAi.maxTokensByAction).length > 0
308
423
  ? ` · por ação: ${Object.entries(fileAi.maxTokensByAction).map(([a, t]) => `${a}=${t}`).join(', ')}`
309
424
  : '';
310
- notes.push(`Teto de saída: ${maxTokensNote}${byAction}.`);
425
+ const actionDefaults = Object.entries(MAX_TOKENS_BY_ACTION)
426
+ .filter(([a]) => !fileAi.maxTokensByAction?.[a] && !fileAi.maxTokens)
427
+ .map(([a, t]) => `${a}=${t} (default)`)
428
+ .join(', ');
429
+ notes.push(
430
+ `Teto de saída: ${maxTokensNote}${byAction}${actionDefaults ? ` · ${actionDefaults}` : ''}.`
431
+ );
311
432
 
312
433
  let status = 'ok';
434
+
435
+ // Escalada da crítica: sem escalationModel, a segunda tentativa apenas repete o
436
+ // MESMO modelo — a escalada existe no fluxo mas não muda nada.
437
+ const maxAttempts = Number.isInteger(fileAi.maxCritiqueAttempts) && fileAi.maxCritiqueAttempts > 0
438
+ ? fileAi.maxCritiqueAttempts
439
+ : DEFAULT_MAX_CRITIQUE_ATTEMPTS;
440
+ const critiqueModel = fileAi.models?.critique || model;
441
+ if (!fileAi.escalationModel) {
442
+ status = 'warn';
443
+ notes.push(
444
+ `Escalada da crítica: nenhuma (\`ai.escalationModel\` ausente) — na tentativa 2 a crítica ` +
445
+ `repete o mesmo modelo (\`${critiqueModel}\`). Teto: ${maxAttempts} tentativas → ` +
446
+ `label \`${LABEL_NEEDS_HUMAN}\`.`
447
+ );
448
+ } else if (fileAi.escalationModel === critiqueModel) {
449
+ status = 'warn';
450
+ notes.push(
451
+ `Escalada da crítica: \`ai.escalationModel\` é igual ao modelo da crítica ` +
452
+ `(\`${critiqueModel}\`) — escalar não troca de modelo. Teto: ${maxAttempts} tentativas.`
453
+ );
454
+ } else {
455
+ notes.push(
456
+ `Escalada da crítica: \`${fileAi.escalationModel}\` a partir da tentativa 2 · ` +
457
+ `teto de ${maxAttempts} tentativas → label \`${LABEL_NEEDS_HUMAN}\`.`
458
+ );
459
+ }
460
+ if (Number.isInteger(fileAi.maxCritiqueAttempts) === false && fileAi.maxCritiqueAttempts !== undefined) {
461
+ status = 'warn';
462
+ notes.push(
463
+ `\`ai.maxCritiqueAttempts\` = ${JSON.stringify(fileAi.maxCritiqueAttempts)} não é um inteiro ` +
464
+ `positivo — usando o default (${DEFAULT_MAX_CRITIQUE_ATTEMPTS}).`
465
+ );
466
+ }
467
+
468
+ // Apelidos de modelo por label (spec-wave:model:<apelido>).
469
+ const aliases = fileAi.modelAliases && Object.keys(fileAi.modelAliases).length > 0
470
+ ? fileAi.modelAliases
471
+ : null;
472
+ if (!aliases) {
473
+ notes.push(
474
+ `Apelidos de modelo: nenhum (\`ai.modelAliases\` ausente) — labels ` +
475
+ `\`${MODEL_LABEL_PREFIX}<apelido>\` serão ignoradas.`
476
+ );
477
+ } else {
478
+ notes.push(
479
+ `Apelidos de modelo (\`${MODEL_LABEL_PREFIX}<apelido>\`): ` +
480
+ `${Object.entries(aliases).map(([a, m]) => `${a}=${m}`).join(', ')}.`
481
+ );
482
+ // Slug da OpenRouter tem "/" (anthropic/claude-…); id da Anthropic, não.
483
+ const wrongShape = Object.entries(aliases).filter(([, m]) => (
484
+ provider.value === 'openrouter' ? !String(m).includes('/') : String(m).includes('/')
485
+ ));
486
+ if (wrongShape.length > 0) {
487
+ status = 'warn';
488
+ notes.push(
489
+ `Apelidos com formato incompatível com o provider ${provider.value}: ` +
490
+ `${wrongShape.map(([a, m]) => `${a}=${m}`).join(', ')}.`
491
+ );
492
+ }
493
+ }
494
+
495
+ // Saída estruturada da crítica: sem structured output confiável, a validação
496
+ // do schema queima os retries antes de falhar.
497
+ if (provider.value === 'openrouter' && !fileAi.models?.critique) {
498
+ status = 'warn';
499
+ notes.push(
500
+ `Saída estruturada da crítica: o provider é openrouter e \`ai.models.critique\` não está ` +
501
+ `definido — \`${model}\` pode não ser roteado para um endpoint com \`response_format\`, ` +
502
+ 'e a crítica falha na primeira execução. Aponte `ai.models.critique` para um modelo com ' +
503
+ 'saída estruturada.'
504
+ );
505
+ } else {
506
+ notes.push(
507
+ `Saída estruturada da crítica: ${provider.value === 'anthropic' ? 'tool call forçado' : 'response_format json_schema'} ` +
508
+ `· strict=${supportsStrictSchema(critiqueModel) ? 'sim' : 'não'} neste modelo.`
509
+ );
510
+ }
313
511
  if (process.env[provider.secret]) {
314
512
  notes.push(`${provider.secret} presente no ambiente local.`);
315
513
  } else {
@@ -377,7 +575,9 @@ export function checkSpecKit(ctx) {
377
575
 
378
576
  async function checkWorkflows(ctx) {
379
577
  const name = 'Workflows do Actions';
380
- const dir = path.join(ctx.cwd, '.github', 'workflows');
578
+ // Ancorado na raiz do projeto, não no cwd: rodar o doctor de um subdiretório
579
+ // reportava ".github/workflows/ não encontrado" mesmo com tudo instalado.
580
+ const dir = path.join(ctx.root || ctx.cwd, '.github', 'workflows');
381
581
  if (!existsSync(dir)) {
382
582
  return {
383
583
  name,
@@ -394,7 +594,33 @@ async function checkWorkflows(ctx) {
394
594
  detail: `Workflows faltando em .github/workflows/: ${missing.join(', ')} — rode \`npx @spec-wave/cli@latest update\`.`,
395
595
  };
396
596
  }
397
- return { name, status: 'ok', detail: `Os ${WORKFLOW_FILES.length} workflows do spec-wave estão presentes.` };
597
+
598
+ // Versão da CLI fixada nos workflows: um `@latest` deixa uma release mudar o
599
+ // comportamento de pipelines já em andamento.
600
+ const unpinned = [];
601
+ const otherVersion = [];
602
+ for (const file of WORKFLOW_FILES) {
603
+ const content = readFileSync(path.join(dir, file), 'utf-8');
604
+ if (/@spec-wave\/cli@latest/.test(content)) unpinned.push(file);
605
+ else if (!content.includes(`@spec-wave/cli@${CLI_VERSION}`)) otherVersion.push(file);
606
+ }
607
+ const notes = [`Os ${WORKFLOW_FILES.length} workflows do spec-wave estão presentes.`];
608
+ let status = 'ok';
609
+ if (unpinned.length > 0) {
610
+ status = 'warn';
611
+ notes.push(
612
+ `Chamando \`@spec-wave/cli@latest\` (sem versão fixada): ${unpinned.join(', ')}. ` +
613
+ 'Uma release da CLI muda o comportamento de pipelines em andamento — rode `update`.'
614
+ );
615
+ }
616
+ if (otherVersion.length > 0) {
617
+ status = 'warn';
618
+ notes.push(
619
+ `Fixados em outra versão que não a ${CLI_VERSION}: ${otherVersion.join(', ')} — rode \`update\` ` +
620
+ 'para fazer o bump explícito.'
621
+ );
622
+ }
623
+ return { name, status, detail: notes.join('\n') };
398
624
  }
399
625
 
400
626
  // ── Comando ─────────────────────────────────────────────────────────────────
@@ -404,11 +630,14 @@ export async function doctor() {
404
630
 
405
631
  // Contexto compartilhado entre os checks (token, config, resultados parciais).
406
632
  const ctx = { cwd: process.cwd() };
407
- ctx.configPath = path.join(ctx.cwd, CONFIG_FILE);
633
+ // Procurado subindo na árvore: rodar o doctor de um subdiretório deve
634
+ // diagnosticar o repositório, não reportar "não inicializado".
635
+ ctx.configPath = findConfigPath(ctx.cwd);
636
+ ctx.root = ctx.configPath ? path.dirname(ctx.configPath) : null;
408
637
  ctx.cfg = null;
409
638
  ctx.cfgError = null;
410
639
  try {
411
- if (existsSync(ctx.configPath)) {
640
+ if (ctx.configPath) {
412
641
  ctx.cfg = JSON.parse(readFileSync(ctx.configPath, 'utf-8'));
413
642
  }
414
643
  } catch (err) {
@@ -421,6 +650,7 @@ export async function doctor() {
421
650
  checkGhAccount,
422
651
  checkConfig,
423
652
  checkRepoAccess,
653
+ checkBoardHygiene,
424
654
  checkAi,
425
655
  checkSpecKit,
426
656
  checkWorkflows,
@@ -1,13 +1,22 @@
1
1
  import { execSync } from 'node:child_process';
2
+ import path from 'node:path';
2
3
  import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
3
4
  import { resolveToken } from '../api/auth.mjs';
4
- import { getIssue, removeLabel, addLabel, commentOnIssue } from '../api/github-rest.mjs';
5
+ import {
6
+ getIssue, removeLabel, addLabel, commentOnIssue, listIssueComments,
7
+ } from '../api/github-rest.mjs';
5
8
  import { detectIssueType } from '../lib/issue-type.mjs';
6
- import { allowsSpecPlan, SPEC_PLAN_EXCLUDED_TYPES, TARGET_LANGUAGE, LABEL_CRITIQUE_FAILED } from '../config.mjs';
9
+ import {
10
+ allowsSpecPlan, SPEC_PLAN_EXCLUDED_TYPES, TARGET_LANGUAGE,
11
+ LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN, DEFAULT_MAX_CRITIQUE_ATTEMPTS, labelNames,
12
+ } from '../config.mjs';
7
13
  import { generateDocument } from '../lib/claude.mjs';
8
- import { runCritique } from '../lib/critique.mjs';
14
+ import {
15
+ runCritique, resolveCritiqueAttempt, renderNeedsHumanComment,
16
+ } from '../lib/critique.mjs';
9
17
  import { recordUsage } from '../lib/usage-report.mjs';
10
18
  import { slugify } from '../lib/slugify.mjs';
19
+ import { loadConfig, resolveFromRoot } from '../lib/project-root.mjs';
11
20
  import { buildTechContext } from '../lib/tech-context.mjs';
12
21
 
13
22
  // Aviso anexado ao comentário quando o lint de idioma ainda reprova após o
@@ -41,9 +50,82 @@ Regras OBRIGATÓRIAS:
41
50
  - Forneça detalhes acionáveis: caminhos exatos de endpoints, nomes de DTOs, constraints de banco.
42
51
  - Responda APENAS com o conteúdo do plan.md, sem texto adicional.`;
43
52
 
53
+ /**
54
+ * Roda a crítica do plan e aplica as labels de bloqueio.
55
+ *
56
+ * Extraída do fluxo principal por dois motivos:
57
+ * • o `commentOnIssue` do resultado estava DENTRO do mesmo try do runCritique,
58
+ * então uma falha ao comentar pulava o addLabel(critique-failed) e a crítica
59
+ * grave deixava de bloquear em silêncio;
60
+ * • a escalada de modelo e o teto de tentativas precisam do contador derivado
61
+ * dos comentários da issue.
62
+ *
63
+ * Nunca lança: o plan já foi commitado e o humano precisa dele para corrigir.
64
+ */
65
+ async function critiquePlan({
66
+ token, owner, repo, issueNumber, spec, plan, techContextYaml, labels, config, usage,
67
+ }) {
68
+ if (labels.includes(LABEL_NEEDS_HUMAN)) {
69
+ console.log(`Crítica pulada: a issue tem a label ${LABEL_NEEDS_HUMAN} (revisão humana pendente).`);
70
+ return;
71
+ }
72
+
73
+ const maxAttempts = Number.isInteger(config?.ai?.maxCritiqueAttempts) && config.ai.maxCritiqueAttempts > 0
74
+ ? config.ai.maxCritiqueAttempts
75
+ : DEFAULT_MAX_CRITIQUE_ATTEMPTS;
76
+ const escalationModel = config?.ai?.escalationModel || null;
77
+
78
+ const comments = await listIssueComments(token, owner, repo, issueNumber).catch(err => {
79
+ console.warn(`Não foi possível listar comentários: ${err.message} — contador de tentativas em 1.`);
80
+ return [];
81
+ });
82
+ const { attempt, blocked } = resolveCritiqueAttempt({ comments, labels, kind: 'plan', maxAttempts });
83
+
84
+ if (blocked) {
85
+ console.log(`Teto de ${maxAttempts} tentativas de crítica atingido — exigindo revisão humana.`);
86
+ await commentOnIssue(token, owner, repo, issueNumber,
87
+ renderNeedsHumanComment({ kind: 'plan', attempt, maxAttempts, escalationModel })).catch(() => {});
88
+ await addLabel(token, owner, repo, issueNumber, LABEL_NEEDS_HUMAN).catch(() => {});
89
+ return;
90
+ }
91
+
92
+ const model = attempt > 1 ? (escalationModel || undefined) : undefined;
93
+ if (model) console.log(`Tentativa ${attempt}: escalando a crítica para ${model}.`);
94
+
95
+ let critique;
96
+ try {
97
+ critique = await runCritique({
98
+ kind: 'plan', spec, plan, techContextYaml, attempt, maxAttempts, model, labels, usage,
99
+ });
100
+ } catch (err) {
101
+ console.warn(`Crítica adversarial não concluiu: ${err.message}`);
102
+ await commentOnIssue(token, owner, repo, issueNumber,
103
+ `⚠️ **A crítica adversarial não concluiu** (erro abaixo) — o \`plan.md\` foi gerado, ` +
104
+ 'mas **não foi auditado**. Revise-o com atenção extra.\n\n' +
105
+ `\`\`\`\n${err.message}\n\`\`\``
106
+ ).catch(() => {});
107
+ return;
108
+ }
109
+
110
+ // O comentário fica FORA do try da label: falhar ao comentar não pode impedir
111
+ // o bloqueio de uma crítica grave.
112
+ await commentOnIssue(token, owner, repo, issueNumber, critique.markdown)
113
+ .catch(err => console.warn(`Falha ao comentar a crítica: ${err.message}`));
114
+
115
+ if (critique.grave) {
116
+ await addLabel(token, owner, repo, issueNumber, LABEL_CRITIQUE_FAILED);
117
+ console.log(`Crítica adversarial apontou findings GRAVES — label ${LABEL_CRITIQUE_FAILED} aplicada.`);
118
+ return;
119
+ }
120
+ // Crítica limpa remove o bloqueio anterior — é isso que zera o contador de
121
+ // tentativas na rodada seguinte (ver resolveCritiqueAttempt).
122
+ await removeLabel(token, owner, repo, issueNumber, LABEL_CRITIQUE_FAILED).catch(() => {});
123
+ }
124
+
44
125
  export async function generatePlan({ issueNumber }) {
45
126
  const token = await resolveToken();
46
127
  const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/');
128
+ const { config, root } = loadConfig();
47
129
 
48
130
  if (!owner || !repo) {
49
131
  throw new Error(
@@ -58,6 +140,8 @@ export async function generatePlan({ issueNumber }) {
58
140
 
59
141
  // plan.md é artefato de Feature — não se aplica a Spike/RFC/Bug.
60
142
  const type = detectIssueType(issue);
143
+ // Alimentam o override de modelo por label (spec-wave:model:<apelido>).
144
+ const issueLabels = labelNames(issue);
61
145
  if (!allowsSpecPlan(type)) {
62
146
  console.log(`Issue #${issueNumber} é ${type}: plan.md não é gerado para ${SPEC_PLAN_EXCLUDED_TYPES.join('/')}.`);
63
147
  await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:plan');
@@ -70,11 +154,15 @@ export async function generatePlan({ issueNumber }) {
70
154
  }
71
155
 
72
156
  const slug = slugify(issue.title);
73
- const featureDir = `docs/features/${slug}`;
74
- const filePath = `${featureDir}/plan.md`;
157
+ // Caminho RELATIVO para links/commit; ABSOLUTO ancorado na raiz para o fs — o
158
+ // config é procurado subindo na árvore, e os documentos moram junto dele.
159
+ const featureRel = `docs/features/${slug}`;
160
+ const featureDir = resolveFromRoot(root, featureRel);
161
+ const filePath = path.join(featureDir, 'plan.md');
162
+ const fileRel = `${featureRel}/plan.md`;
75
163
 
76
164
  // Read existing spec.md if available (spec é gerada antes do plano)
77
- const specPath = `${featureDir}/spec.md`;
165
+ const specPath = path.join(featureDir, 'spec.md');
78
166
  const specContent = existsSync(specPath) ? readFileSync(specPath, 'utf-8') : null;
79
167
 
80
168
  // Tech context (RFC-002 §4): estático + dinâmico + override do corpo da issue.
@@ -100,6 +188,7 @@ export async function generatePlan({ issueNumber }) {
100
188
  console.log(`Gerando plan.md para: ${issue.title}`);
101
189
  const { content, lintFindings } = await generateDocument(SYSTEM_PROMPT, userContent, {
102
190
  action: 'plan',
191
+ labels: issueLabels,
103
192
  lint: { lang: TARGET_LANGUAGE },
104
193
  withReport: true,
105
194
  usage: usageEntries,
@@ -124,36 +213,22 @@ export async function generatePlan({ issueNumber }) {
124
213
  await commentOnIssue(
125
214
  token, owner, repo, parseInt(issueNumber, 10),
126
215
  `📋 **plan.md gerado automaticamente!**\n\n` +
127
- `📄 Arquivo: [\`${filePath}\`](https://github.com/${owner}/${repo}/blob/main/${filePath})\n\n` +
216
+ `📄 Arquivo: [\`${fileRel}\`](https://github.com/${owner}/${repo}/blob/main/${fileRel})\n\n` +
128
217
  `Revise o plano e, quando estiver pronto, valide a Feature: mova o card para **✅ Ready** ou use:\n` +
129
218
  `\`\`\`\ngh issue edit ${issueNumber} --add-label "spec-wave:ready"\n\`\`\`` +
130
219
  formatLintWarning(lintFindings)
131
220
  );
132
221
 
133
222
  // Crítica adversarial: audita o plan recém-comitado contra spec +
134
- // tech_context. NUNCA desfaz o plan — falha da crítica vira só um aviso.
135
- try {
136
- const critique = await runCritique({
137
- kind: 'plan',
138
- spec: specContent,
139
- plan: content,
140
- techContextYaml: tech.yaml,
141
- usage: usageEntries,
142
- });
143
- await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10), critique.markdown);
144
- if (critique.grave) {
145
- await addLabel(token, owner, repo, parseInt(issueNumber, 10), LABEL_CRITIQUE_FAILED);
146
- console.log(`Crítica adversarial apontou findings GRAVES — label ${LABEL_CRITIQUE_FAILED} aplicada.`);
147
- }
148
- } catch (err) {
149
- console.warn(`Crítica adversarial indisponível: ${err.message}`);
150
- await commentOnIssue(
151
- token, owner, repo, parseInt(issueNumber, 10),
152
- `⚠️ crítica adversarial indisponível (erro: ${err.message})`
153
- ).catch(() => {});
154
- }
155
-
156
- console.log(`plan.md criado em: ${filePath}`);
223
+ // tech_context. NUNCA desfaz o plan — falha da crítica vira só um aviso, e o
224
+ // humano segue com a ferramenta que precisa para corrigir.
225
+ await critiquePlan({
226
+ token, owner, repo, issueNumber: parseInt(issueNumber, 10),
227
+ spec: specContent, plan: content, techContextYaml: tech.yaml,
228
+ labels: issueLabels, config, usage: usageEntries,
229
+ });
230
+
231
+ console.log(`plan.md criado em: ${fileRel}`);
157
232
  } catch (err) {
158
233
  // Mesmo beco sem saída do generate-spec: o gatilho é `issues: [labeled]`,
159
234
  // então com a label ainda aplicada re-adicioná-la não dispara nada. Remove
@@ -1,12 +1,16 @@
1
1
  import { execSync } from 'node:child_process';
2
+ import path from 'node:path';
2
3
  import { mkdirSync, writeFileSync } from 'node:fs';
3
4
  import { resolveToken } from '../api/auth.mjs';
4
5
  import { getIssue, removeLabel, commentOnIssue } from '../api/github-rest.mjs';
5
6
  import { generateDocument } from '../lib/claude.mjs';
6
7
  import { recordUsage } from '../lib/usage-report.mjs';
7
8
  import { slugify } from '../lib/slugify.mjs';
9
+ import { loadConfig, resolveFromRoot } from '../lib/project-root.mjs';
8
10
  import { detectIssueType } from '../lib/issue-type.mjs';
9
- import { allowsSpecPlan, SPEC_PLAN_EXCLUDED_TYPES, TARGET_LANGUAGE } from '../config.mjs';
11
+ import {
12
+ allowsSpecPlan, SPEC_PLAN_EXCLUDED_TYPES, TARGET_LANGUAGE, labelNames,
13
+ } from '../config.mjs';
10
14
 
11
15
  // Aviso anexado ao comentário quando o lint de idioma ainda reprova após o
12
16
  // retry automático do generateDocument (excertos ao redor de cada vazamento).
@@ -44,6 +48,7 @@ Regras:
44
48
  export async function generateSpec({ issueNumber }) {
45
49
  const token = await resolveToken();
46
50
  const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/');
51
+ const { root } = loadConfig();
47
52
 
48
53
  if (!owner || !repo) {
49
54
  throw new Error(
@@ -59,6 +64,8 @@ export async function generateSpec({ issueNumber }) {
59
64
  // spec.md/plan.md são artefatos funcionais de Feature — não se aplicam a
60
65
  // Spike/RFC/Bug. Pula a geração, remove o trigger e avisa na issue.
61
66
  const type = detectIssueType(issue);
67
+ // Alimentam o override de modelo por label (spec-wave:model:<apelido>).
68
+ const issueLabels = labelNames(issue);
62
69
  if (!allowsSpecPlan(type)) {
63
70
  console.log(`Issue #${issueNumber} é ${type}: spec.md não é gerada para ${SPEC_PLAN_EXCLUDED_TYPES.join('/')}.`);
64
71
  await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:spec');
@@ -71,8 +78,12 @@ export async function generateSpec({ issueNumber }) {
71
78
  }
72
79
 
73
80
  const slug = slugify(issue.title);
74
- const featureDir = `docs/features/${slug}`;
75
- const filePath = `${featureDir}/spec.md`;
81
+ // Caminho RELATIVO para links/commit; ABSOLUTO ancorado na raiz para o fs — o
82
+ // config é procurado subindo na árvore, e os documentos moram junto dele.
83
+ const featureRel = `docs/features/${slug}`;
84
+ const featureDir = resolveFromRoot(root, featureRel);
85
+ const filePath = path.join(featureDir, 'spec.md');
86
+ const fileRel = `${featureRel}/spec.md`;
76
87
 
77
88
  // Payload estruturado (RFC-002 §5.1): metadata + entrada de negócio.
78
89
  const payload = {
@@ -94,6 +105,7 @@ export async function generateSpec({ issueNumber }) {
94
105
  console.log(`Gerando spec.md para: ${issue.title}`);
95
106
  const { content, lintFindings } = await generateDocument(SYSTEM_PROMPT, userContent, {
96
107
  action: 'spec',
108
+ labels: issueLabels,
97
109
  lint: { lang: TARGET_LANGUAGE },
98
110
  withReport: true,
99
111
  usage: usageEntries,
@@ -118,13 +130,13 @@ export async function generateSpec({ issueNumber }) {
118
130
  await commentOnIssue(
119
131
  token, owner, repo, parseInt(issueNumber, 10),
120
132
  `📋 **spec.md gerado automaticamente!**\n\n` +
121
- `📄 Arquivo: [\`${filePath}\`](https://github.com/${owner}/${repo}/blob/main/${filePath})\n\n` +
133
+ `📄 Arquivo: [\`${fileRel}\`](https://github.com/${owner}/${repo}/blob/main/${fileRel})\n\n` +
122
134
  `Revise a especificação e, quando estiver pronto, gere o plano técnico: mova o card para **📋 Plan** ou use:\n` +
123
135
  `\`\`\`\ngh issue edit ${issueNumber} --add-label "spec-wave:plan"\n\`\`\`` +
124
136
  formatLintWarning(lintFindings)
125
137
  );
126
138
 
127
- console.log(`spec.md criado em: ${filePath}`);
139
+ console.log(`spec.md criado em: ${fileRel}`);
128
140
  } catch (err) {
129
141
  // Sem isto a label de gatilho fica aplicada — e como o workflow dispara em
130
142
  // `issues: [labeled]`, re-adicionar uma label já presente não emite evento: