@spec-wave/cli 0.6.0 → 0.7.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.
@@ -0,0 +1,183 @@
1
+ // Gerencia uma Task no board: start (Etapa "🚧 Desenvolvimento" + Status
2
+ // "In Progress") | done (Etapa "🎉 Done" + Status "Done").
3
+ //
4
+ // Comando LOCAL — rodado pelo dev no terminal. owner/repo vêm da env
5
+ // GITHUB_REPOSITORY quando existir, senão do .spec-wave.json (gravado pelo init).
6
+ //
7
+ // Regras do board embutidas (ver config.mjs):
8
+ // • Etapa nunca retrocede (advanceToStage devolve false nesse caso);
9
+ // • uma única Task com Status "In Progress" por vez dentro da mesma Story
10
+ // (regra pura canStartTask, testada em test/board-rules.test.mjs).
11
+ import * as p from '@clack/prompts';
12
+ import chalk from 'chalk';
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import path from 'node:path';
15
+ import { resolveToken } from '../api/auth.mjs';
16
+ import { getIssue } from '../api/github-rest.mjs';
17
+ import { addProjectItem, getIssueParent, listSubIssues, getItemSingleSelectValue } from '../api/github-graphql.mjs';
18
+ import { detectIssueType } from '../lib/issue-type.mjs';
19
+ import { loadProjectConfig, resolveField, advanceToStage, setItemStatus } from '../lib/board.mjs';
20
+ import {
21
+ CONFIG_FILE, STAGE_DEVELOPMENT, STAGE_DONE,
22
+ PROGRESS_IN_PROGRESS, PROGRESS_DONE,
23
+ } from '../config.mjs';
24
+
25
+ /**
26
+ * Regra PURA: pode iniciar uma Task? Só se NENHUMA task irmã (mesma Story)
27
+ * estiver com Status "In Progress". Irmãs com status desconhecido (null/
28
+ * undefined) NÃO bloqueiam — falha de leitura não pode travar o fluxo.
29
+ *
30
+ * @param {{ siblings: Array<{ number: number, status: string|null }> }} input
31
+ * @returns {{ ok: boolean, blocker: number|null }} blocker = number da irmã
32
+ * em andamento, quando houver
33
+ */
34
+ export function canStartTask({ siblings } = {}) {
35
+ const busy = (siblings || []).find(s => s && s.status === PROGRESS_IN_PROGRESS);
36
+ return busy ? { ok: false, blocker: busy.number } : { ok: true, blocker: null };
37
+ }
38
+
39
+ // Resolve owner/repo: env GITHUB_REPOSITORY (padrão dos comandos de Action) com
40
+ // fallback no .spec-wave.json — comandos locais rodam sem essa env.
41
+ function resolveRepoContext() {
42
+ const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
43
+ let cfg = {};
44
+ const cfgPath = path.join(process.cwd(), CONFIG_FILE);
45
+ try { if (existsSync(cfgPath)) cfg = JSON.parse(readFileSync(cfgPath, 'utf-8')); } catch {}
46
+ return { owner: envOwner || cfg.owner, repo: envRepo || cfg.repo };
47
+ }
48
+
49
+ export async function task({ action, issue: issueArg }) {
50
+ if (action !== 'start' && action !== 'done') {
51
+ p.log.error(
52
+ `Ação desconhecida: "${action}".\n` +
53
+ 'Uso: spec-wave task <start|done> <número> — ex.: spec-wave task start 12'
54
+ );
55
+ process.exitCode = 1;
56
+ return;
57
+ }
58
+
59
+ const issueNumber = parseInt(String(issueArg).replace('#', ''), 10);
60
+ if (!Number.isInteger(issueNumber) || issueNumber <= 0) {
61
+ p.log.error(`Issue inválida: "${issueArg}". Use o número da issue, ex.: 12 ou #12.`);
62
+ process.exitCode = 1;
63
+ return;
64
+ }
65
+
66
+ const { owner, repo } = resolveRepoContext();
67
+ if (!owner || !repo) {
68
+ p.log.error(
69
+ 'Não foi possível determinar owner/repo.\n' +
70
+ `Rode dentro de um repositório com ${CONFIG_FILE} (\`spec-wave init\`) ou defina GITHUB_REPOSITORY=owner/repo.`
71
+ );
72
+ process.exitCode = 1;
73
+ return;
74
+ }
75
+
76
+ let token;
77
+ try {
78
+ token = await resolveToken();
79
+ } catch (err) {
80
+ p.log.error(err.message);
81
+ process.exitCode = 1;
82
+ return;
83
+ }
84
+
85
+ p.intro(chalk.bold(`spec-wave task ${action} #${issueNumber}`));
86
+
87
+ // 1. Lê a issue e valida o tipo.
88
+ let issue;
89
+ try {
90
+ issue = await getIssue(token, owner, repo, issueNumber);
91
+ } catch (err) {
92
+ p.log.error(`Não foi possível ler a issue #${issueNumber}: ${err.message}`);
93
+ process.exitCode = 1;
94
+ return;
95
+ }
96
+ const type = detectIssueType(issue);
97
+ if (type !== 'Task') {
98
+ p.log.error(
99
+ `\`spec-wave task\` só aceita issues do tipo Task. ` +
100
+ `Issue #${issueNumber} é do tipo ${type || 'desconhecido'} (${issue.title}).`
101
+ );
102
+ process.exitCode = 1;
103
+ return;
104
+ }
105
+
106
+ // 2. Project do .spec-wave.json — sem ele não há board para atualizar.
107
+ const { project, error: projectError } = loadProjectConfig();
108
+ if (projectError) {
109
+ p.log.error(`${projectError} — board não atualizado. Rode \`spec-wave init\` (ou \`spec-wave refresh --config\`).`);
110
+ process.exitCode = 1;
111
+ return;
112
+ }
113
+ const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
114
+ const statusField = await resolveField(token, project, 'Status').catch(() => null);
115
+
116
+ // 3. start: garante que nenhuma task irmã (mesma Story) está In Progress.
117
+ if (action === 'start') {
118
+ const parent = await getIssueParent(token, issue.node_id).catch(() => null);
119
+ const parentIsStory = parent && detectIssueType({ title: parent.title }) === 'Story';
120
+ if (!statusField?.id) {
121
+ p.log.warn('Campo "Status" não encontrado no Project — verificação de task em andamento pulada.');
122
+ } else if (!parentIsStory) {
123
+ p.log.warn(`Task #${issueNumber} sem Story-pai — verificação de tasks irmãs pulada.`);
124
+ } else {
125
+ const subs = await listSubIssues(token, parent.nodeId).catch(() => []);
126
+ const siblingTasks = subs.filter(s =>
127
+ s.number !== issueNumber && detectIssueType({ title: s.title, labels: s.labels }) === 'Task'
128
+ );
129
+ // Status de cada irmã; falha de leitura → status desconhecido (não bloqueia).
130
+ const siblings = await Promise.all(siblingTasks.map(async (s) => {
131
+ try {
132
+ const itemId = await addProjectItem(token, project.id, s.nodeId);
133
+ const status = await getItemSingleSelectValue(token, itemId, statusField.id);
134
+ return { number: s.number, title: s.title, status };
135
+ } catch {
136
+ return { number: s.number, title: s.title, status: null };
137
+ }
138
+ }));
139
+ const { ok, blocker } = canStartTask({ siblings });
140
+ if (!ok) {
141
+ const b = siblings.find(s => s.number === blocker);
142
+ p.log.error(
143
+ `já existe task em andamento: #${blocker} «${b?.title || ''}» — ` +
144
+ `finalize com \`spec-wave task done ${blocker}\` antes de iniciar outra`
145
+ );
146
+ process.exitCode = 1;
147
+ return;
148
+ }
149
+ }
150
+ }
151
+
152
+ // 4. Move no board. start → Desenvolvimento/In Progress; done → Done/Done.
153
+ const target = action === 'start'
154
+ ? { stage: STAGE_DEVELOPMENT, status: PROGRESS_IN_PROGRESS }
155
+ : { stage: STAGE_DONE, status: PROGRESS_DONE };
156
+
157
+ let moved;
158
+ try {
159
+ moved = await advanceToStage(token, project, etapaField, statusField, issue.node_id, target.stage, target.status);
160
+ } catch (err) {
161
+ p.log.error(`Falha ao atualizar o board: ${err.message}`);
162
+ process.exitCode = 1;
163
+ return;
164
+ }
165
+
166
+ if (moved) {
167
+ p.log.success(`Task #${issueNumber} → Etapa ${chalk.bold(target.stage)} / Status ${chalk.bold(target.status)}.`);
168
+ } else {
169
+ // Etapa já está no destino ou adiante (nunca retrocede) — só garante o Status.
170
+ try {
171
+ await setItemStatus(token, project, statusField, issue.node_id, target.status);
172
+ p.log.info(`Etapa já à frente — apenas Status ajustado para ${chalk.bold(target.status)}.`);
173
+ } catch (err) {
174
+ p.log.warn(`Etapa já à frente, mas falhou ao ajustar o Status: ${err.message}`);
175
+ }
176
+ }
177
+
178
+ p.outro(
179
+ action === 'start'
180
+ ? `${chalk.green('✓')} Task #${issueNumber} em andamento. Ao concluir: spec-wave task done ${issueNumber}`
181
+ : `${chalk.green('✓')} Task #${issueNumber} concluída no board.`
182
+ );
183
+ }
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { resolveToken } from '../api/auth.mjs';
4
4
  import { getIssue, removeLabel, addLabel, commentOnIssue } from '../api/github-rest.mjs';
5
5
  import { slugify } from '../lib/slugify.mjs';
6
- import { CONFIG_FILE, REQUIRED_PLAN_SECTIONS, REQUIRED_SPEC_SECTIONS } from '../config.mjs';
6
+ import { CONFIG_FILE, LABEL_CRITIQUE_FAILED, REQUIRED_PLAN_SECTIONS, REQUIRED_SPEC_SECTIONS } from '../config.mjs';
7
7
 
8
8
  export async function validate({ issueNumber }) {
9
9
  const token = await resolveToken();
@@ -27,6 +27,17 @@ export async function validate({ issueNumber }) {
27
27
 
28
28
  const errors = [];
29
29
 
30
+ // Bloqueio da crítica adversarial: enquanto a label critique-failed estiver
31
+ // na issue, o ready não é liberado — a correção dos documentos é manual.
32
+ const labelNames = (issue.labels || []).map((l) => (typeof l === 'string' ? l : l.name));
33
+ const critiqueFailed = labelNames.includes(LABEL_CRITIQUE_FAILED);
34
+ if (critiqueFailed) {
35
+ errors.push(
36
+ '🔎 A crítica adversarial apontou contradições GRAVES (veja o comentário na issue). ' +
37
+ `Corrija os documentos e remova a label \`${LABEL_CRITIQUE_FAILED}\` para liberar o ready.`
38
+ );
39
+ }
40
+
30
41
  // Check plan.md
31
42
  const planPath = `${featureDir}/plan.md`;
32
43
  if (!existsSync(planPath)) {
@@ -63,8 +74,14 @@ export async function validate({ issueNumber }) {
63
74
  errors.join('\n') +
64
75
  `\n\nCorreija os problemas e adicione novamente a label \`spec-wave:ready\`.`
65
76
  );
66
- // Send back to spec stage
67
- await addLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:spec');
77
+ // Quando a ÚNICA falha é a da crítica adversarial, os documentos existem e
78
+ // estão estruturalmente válidos precisam de correção manual. Nesse
79
+ // caso NÃO devolvemos a feature para a etapa de spec (spec-wave:spec).
80
+ const onlyCritiqueFailed = critiqueFailed && errors.length === 1;
81
+ if (!onlyCritiqueFailed) {
82
+ // Send back to spec stage
83
+ await addLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:spec');
84
+ }
68
85
  console.error('Validação falhou:', errors.join(', '));
69
86
  process.exit(1);
70
87
  }
package/src/config.mjs CHANGED
@@ -32,6 +32,16 @@ export const AI_PROVIDERS = [
32
32
 
33
33
  export const DEFAULT_PROVIDER = 'anthropic';
34
34
 
35
+ // Ações de IA que podem ter modelo próprio no .spec-wave.json (bloco
36
+ // `ai.models`, ex.: { "critique": "claude-opus-4-1" }). Resolvidas em runtime
37
+ // por resolveAiConfig() em src/lib/claude.mjs.
38
+ export const AI_ACTIONS = ['spec', 'plan', 'decompose', 'critique'];
39
+
40
+ // Idioma-alvo de todos os documentos gerados por IA (spec/plan/decompose/critique).
41
+ // Usado pelo lint de saída (src/lib/output-lint.mjs) para detectar vazamento
42
+ // de caracteres de outros alfabetos.
43
+ export const TARGET_LANGUAGE = 'pt-BR';
44
+
35
45
  export function getProvider(value) {
36
46
  return AI_PROVIDERS.find(p => p.value === value);
37
47
  }
@@ -131,6 +141,27 @@ export const WORK_ITEM_TYPES = CUSTOM_FIELDS
131
141
  .find(f => f.name === 'Work Item Type')
132
142
  .options.map(o => o.name);
133
143
 
144
+ // Tipos cujo estágio no board é gerenciado MANUALMENTE: as automações (Actions
145
+ // code-review/qa e o fluxo do implement) NUNCA avançam a Etapa desses itens —
146
+ // o usuário os move à mão. Spike é uma investigação cujo avanço é decisão humana.
147
+ export const MANUAL_STAGE_TYPES = ['Spike'];
148
+ export function isManualStageType(type) {
149
+ return MANUAL_STAGE_TYPES.includes(type);
150
+ }
151
+
152
+ // Tipos que NÃO geram spec.md/plan.md (não passam pelo fluxo funcional de Feature).
153
+ export const SPEC_PLAN_EXCLUDED_TYPES = ['Spike', 'RFC', 'Bug'];
154
+ export function allowsSpecPlan(type) {
155
+ return !SPEC_PLAN_EXCLUDED_TYPES.includes(type);
156
+ }
157
+
158
+ // Tipos que podem ser decompostos e o que geram:
159
+ // • Feature → Stories (+ Tasks) • RFC → Tasks (diretamente, sem Stories)
160
+ export const DECOMPOSE_TARGETS = {
161
+ Feature: 'stories',
162
+ RFC: 'tasks',
163
+ };
164
+
134
165
  export const TYPE_LABELS = [
135
166
  { name: '[INITIATIVE]', color: 'C5DEF5', description: 'Agrupamento estratégico de Epics' },
136
167
  { name: '[EPIC]', color: '7B61FF', description: 'Objetivo estratégico' },
@@ -149,12 +180,18 @@ export const PRIORITY_LABELS = [
149
180
  { name: 'P3', color: 'EDEDED', description: 'Baixa' },
150
181
  ];
151
182
 
183
+ // Labels de estado gravadas pelas automações (não são gatilhos do usuário).
184
+ export const LABEL_CRITIQUE_FAILED = 'spec-wave:critique-failed';
185
+ export const LABEL_DECOMPOSED = 'spec-wave:decomposed';
186
+
152
187
  export const TRIGGER_LABELS = [
153
188
  { name: 'spec-wave:spec', color: 'BFD4F2', description: 'Gerar spec.md via GitHub Action' },
154
189
  { name: 'spec-wave:plan', color: 'BFD4F2', description: 'Gerar plan.md via GitHub Action' },
155
190
  { name: 'spec-wave:ready', color: '0E8A16', description: 'Validar spec+plan e mover para Ready' },
156
191
  { name: 'spec-wave:plan-approved', color: '0E8A16', description: 'Spec+plan validados com sucesso' },
157
192
  { name: 'spec-wave:decompose', color: 'BFD4F2', description: 'Decompor em Stories e Tasks' },
193
+ { name: LABEL_CRITIQUE_FAILED, color: 'B60205', description: 'Crítica adversarial apontou contradições graves' },
194
+ { name: LABEL_DECOMPOSED, color: 'EDEDED', description: 'Feature já decomposta em Stories e Tasks' },
158
195
  ];
159
196
 
160
197
  export const ALL_LABELS = [...TYPE_LABELS, ...PRIORITY_LABELS, ...TRIGGER_LABELS];
@@ -0,0 +1,106 @@
1
+ // Helpers compartilhados de manipulação do board (GitHub Projects v2).
2
+ // Extraídos de code-review.mjs/qa.mjs para uso também pelos comandos de CLI
3
+ // (task/story/order). Ver a distinção Etapa × Status em config.mjs.
4
+ import { existsSync, readFileSync } from 'node:fs';
5
+ import path from 'node:path';
6
+ import { addProjectItem, setItemSingleSelect, getSingleSelectField, getItemSingleSelectValue } from '../api/github-graphql.mjs';
7
+ import { CONFIG_FILE, STAGE_ORDER } from '../config.mjs';
8
+
9
+ /**
10
+ * Carrega o bloco `project` do .spec-wave.json do diretório atual.
11
+ *
12
+ * @param {object} [opts]
13
+ * @param {string} [opts.cwd=process.cwd()] diretório onde procurar o config
14
+ * @returns {{ project: object|null, error: string|null }} project = bloco com
15
+ * id/fields; error = motivo legível quando project é null (compõe os
16
+ * avisos "… — board não atualizado." dos chamadores).
17
+ */
18
+ export function loadProjectConfig({ cwd = process.cwd() } = {}) {
19
+ const configPath = path.join(cwd, CONFIG_FILE);
20
+ if (!existsSync(configPath)) {
21
+ return { project: null, error: `${CONFIG_FILE} não encontrado` };
22
+ }
23
+ let project;
24
+ try {
25
+ project = JSON.parse(readFileSync(configPath, 'utf-8')).project || {};
26
+ } catch (err) {
27
+ return { project: null, error: `${CONFIG_FILE} corrompido (${err.message})` };
28
+ }
29
+ if (!project.id) {
30
+ return { project: null, error: `Project não configurado em ${CONFIG_FILE}` };
31
+ }
32
+ return { project, error: null };
33
+ }
34
+
35
+ /**
36
+ * Resolve um campo SINGLE_SELECT do Project pelo nome: usa os IDs gravados no
37
+ * .spec-wave.json (bloco `fields`, ou o formato legado etapaFieldId/
38
+ * stageOptions para "Etapa") e cai na API GraphQL como fallback.
39
+ *
40
+ * @param {string} token
41
+ * @param {object} project bloco project do .spec-wave.json (precisa de .id)
42
+ * @param {string} name nome do campo (ex.: 'Etapa', 'Status')
43
+ * @returns {Promise<{ id: string, options: Record<string,string> }|null>}
44
+ */
45
+ export async function resolveField(token, project, name) {
46
+ if (project.fields?.[name]) return project.fields[name];
47
+ if (name === 'Etapa' && project.etapaFieldId) {
48
+ return { id: project.etapaFieldId, options: project.stageOptions || {} };
49
+ }
50
+ return await getSingleSelectField(token, project.id, name);
51
+ }
52
+
53
+ /**
54
+ * Avança um item do board para `targetStage` (Etapa) e define o Status para
55
+ * `targetStatus`. Uma issue só AVANÇA: se já estiver em `targetStage` ou em uma
56
+ * etapa posterior (pela ordem canônica STAGE_ORDER), não é tocada.
57
+ *
58
+ * @param {string} token token com scope project
59
+ * @param {object} project bloco project (precisa de .id)
60
+ * @param {{id,options}|null} etapaField campo "Etapa" (ver resolveField)
61
+ * @param {{id,options}|null} statusField campo nativo "Status"
62
+ * @param {string} nodeId node id da issue
63
+ * @param {string} targetStage nome da etapa de destino
64
+ * @param {string} targetStatus valor do Status (Todo/In Progress/Done)
65
+ * @returns {Promise<boolean>} true se avançou; false se já estava adiante
66
+ */
67
+ export async function advanceToStage(token, project, etapaField, statusField, nodeId, targetStage, targetStatus) {
68
+ const itemId = await addProjectItem(token, project.id, nodeId);
69
+
70
+ if (etapaField?.id && targetStage) {
71
+ // Nunca retroceder: compara a etapa atual com a de destino na ordem canônica.
72
+ const current = await getItemSingleSelectValue(token, itemId, etapaField.id).catch(() => null);
73
+ const curIdx = current ? STAGE_ORDER.indexOf(current) : -1;
74
+ const tgtIdx = STAGE_ORDER.indexOf(targetStage);
75
+ if (curIdx !== -1 && tgtIdx !== -1 && curIdx >= tgtIdx) {
76
+ return false; // já está nessa etapa ou adiante — não retrocede
77
+ }
78
+ const optionId = etapaField.options?.[targetStage];
79
+ if (optionId) await setItemSingleSelect(token, project.id, itemId, etapaField.id, optionId);
80
+ }
81
+ if (statusField?.id && targetStatus) {
82
+ const optionId = statusField.options?.[targetStatus];
83
+ if (optionId) await setItemSingleSelect(token, project.id, itemId, statusField.id, optionId);
84
+ }
85
+ return true;
86
+ }
87
+
88
+ /**
89
+ * Define APENAS o Status nativo (Todo/In Progress/Done) de um item, sem tocar
90
+ * na Etapa — usado para marcar progresso dentro da etapa atual.
91
+ *
92
+ * @param {string} token token com scope project
93
+ * @param {object} project bloco project (precisa de .id)
94
+ * @param {{id,options}|null} statusField campo nativo "Status"
95
+ * @param {string} nodeId node id da issue
96
+ * @param {string} status valor de destino (Todo/In Progress/Done)
97
+ * @returns {Promise<boolean>} true se definiu; false se campo/opção ausentes
98
+ */
99
+ export async function setItemStatus(token, project, statusField, nodeId, status) {
100
+ if (!statusField?.id) return false;
101
+ const optionId = statusField.options?.[status];
102
+ if (!optionId) return false;
103
+ const itemId = await addProjectItem(token, project.id, nodeId);
104
+ await setItemSingleSelect(token, project.id, itemId, statusField.id, optionId);
105
+ return true;
106
+ }
@@ -2,11 +2,37 @@ import Anthropic from '@anthropic-ai/sdk';
2
2
  import { readFileSync, existsSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { CONFIG_FILE, getProvider, DEFAULT_PROVIDER } from '../config.mjs';
5
+ import { lintLanguage } from './output-lint.mjs';
6
+
7
+ /**
8
+ * Resolve provider/modelo de IA (função PURA — testável sem process.env nem fs).
9
+ *
10
+ * Precedência do modelo: env.SPEC_WAVE_MODEL → fileAi.models[action] →
11
+ * fileAi.model → default do provider. Provider: env.SPEC_WAVE_PROVIDER →
12
+ * fileAi.provider → default. Assim uma ação específica (ex.: critique) pode
13
+ * usar modelo próprio via bloco `ai.models` do .spec-wave.json.
14
+ *
15
+ * @param {object} params
16
+ * @param {object} [params.env] objeto tipo process.env
17
+ * @param {object} [params.fileAi] bloco `ai` do .spec-wave.json
18
+ * @param {string} [params.action] ação de IA (ver AI_ACTIONS em config.mjs)
19
+ * @returns {{ provider: string, model: string, secret: string }}
20
+ */
21
+ export function resolveAiConfig({ env = {}, fileAi = {}, action } = {}) {
22
+ const provider = (env.SPEC_WAVE_PROVIDER || fileAi.provider || DEFAULT_PROVIDER).toLowerCase();
23
+ const meta = getProvider(provider) || getProvider(DEFAULT_PROVIDER);
24
+ const model = env.SPEC_WAVE_MODEL
25
+ || (action && fileAi.models?.[action])
26
+ || fileAi.model
27
+ || meta.defaultModel;
28
+ return { provider: meta.value, model, secret: meta.secret };
29
+ }
5
30
 
6
31
  // Resolve o provider/modelo de IA a partir do .spec-wave.json (gravado pelo init
7
32
  // e versionado no repo) com precedência para variáveis de ambiente — assim os
8
33
  // workflows usam exatamente o que foi escolhido no init, sem depender de flags.
9
- function resolveAi() {
34
+ // Wrapper fino: lê o arquivo e delega a decisão à resolveAiConfig (pura).
35
+ function resolveAi(action) {
10
36
  let fileAi = {};
11
37
  try {
12
38
  const configPath = path.join(process.cwd(), CONFIG_FILE);
@@ -16,28 +42,62 @@ function resolveAi() {
16
42
  } catch {
17
43
  // config ausente/corrompido → cai nos defaults/env
18
44
  }
19
-
20
- const provider = (process.env.SPEC_WAVE_PROVIDER || fileAi.provider || DEFAULT_PROVIDER).toLowerCase();
21
- const meta = getProvider(provider) || getProvider(DEFAULT_PROVIDER);
22
- const model = process.env.SPEC_WAVE_MODEL || fileAi.model || meta.defaultModel;
23
- return { provider: meta.value, model, secret: meta.secret };
45
+ return resolveAiConfig({ env: process.env, fileAi, action });
24
46
  }
25
47
 
26
48
  // temperature padrão 0.2 (RFC-002 §5): "Determinism over Creativity". Pode ser
27
49
  // sobrescrita por chamada via opts, mas o default cobre spec/plan/decompose.
50
+ //
51
+ // opts:
52
+ // • action: ação de IA ('spec'|'plan'|'decompose'|'critique') — permite modelo
53
+ // por ação via `ai.models` do .spec-wave.json;
54
+ // • lint: { lang } — após gerar, roda lintLanguage no resultado; se reprovar,
55
+ // RE-GERA uma única vez com instrução de idioma reforçada; se reprovar de
56
+ // novo, segue com o conteúdo e reporta os findings;
57
+ // • withReport: true → retorna { content, lintFindings, retried } em vez da
58
+ // string (o retorno string é mantido para os chamadores existentes).
28
59
  export async function generateDocument(systemPrompt, userContent, opts = {}) {
29
- const ai = resolveAi();
60
+ const ai = resolveAi(opts.action);
30
61
  const temperature = opts.temperature ?? 0.2;
31
62
  // Modelos de reasoning (ex.: deepseek-r1) consomem tokens "pensando" antes da
32
63
  // resposta, então o teto precisa ser maior para o plano não vir truncado.
33
64
  const maxTokens = opts.maxTokens ?? 8192;
34
65
  console.log(`Provider de IA: ${ai.provider} · modelo: ${ai.model} · temperature: ${temperature} · max_tokens: ${maxTokens}`);
35
66
 
36
- const raw = ai.provider === 'openrouter'
37
- ? await generateWithOpenRouter(systemPrompt, userContent, ai, temperature, maxTokens)
38
- : await generateWithAnthropic(systemPrompt, userContent, ai, temperature, maxTokens);
67
+ const generate = async (system) => {
68
+ const raw = ai.provider === 'openrouter'
69
+ ? await generateWithOpenRouter(system, userContent, ai, temperature, maxTokens)
70
+ : await generateWithAnthropic(system, userContent, ai, temperature, maxTokens);
71
+ return stripOuterFence(raw);
72
+ };
73
+
74
+ let content = await generate(systemPrompt);
75
+ let lintFindings = [];
76
+ let retried = false;
77
+
78
+ if (opts.lint) {
79
+ const lang = opts.lint.lang || 'pt-BR';
80
+ const allowlist = opts.lint.allowlist || [];
81
+ let result = lintLanguage(content, { lang, allowlist });
82
+ if (!result.ok) {
83
+ // Uma única nova tentativa, com a instrução de idioma reforçada no system
84
+ // prompt — cobre o caso de modelos que vazam caracteres CJK/cirílicos.
85
+ retried = true;
86
+ console.warn(`Lint de idioma reprovou a saída (${result.findings.length} ocorrência(s)) — re-gerando com instrução reforçada.`);
87
+ const reinforced = systemPrompt +
88
+ `\n\nIMPORTANTE: responda exclusivamente em ${lang}. ` +
89
+ 'Não use caracteres de outros alfabetos (CJK, cirílico, árabe, tailandês).';
90
+ content = await generate(reinforced);
91
+ result = lintLanguage(content, { lang, allowlist });
92
+ if (!result.ok) {
93
+ // Segue com o conteúdo mesmo assim; o chamador decide o que fazer.
94
+ console.warn(`Lint de idioma reprovou novamente (${result.findings.length} ocorrência(s)) — seguindo com o conteúdo gerado.`);
95
+ lintFindings = result.findings;
96
+ }
97
+ }
98
+ }
39
99
 
40
- return stripOuterFence(raw);
100
+ return opts.withReport ? { content, lintFindings, retried } : content;
41
101
  }
42
102
 
43
103
  // Remove blocos de raciocínio que alguns modelos (ex.: deepseek-r1) embutem no