@spec-wave/cli 0.18.1 → 0.20.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,232 @@
1
+ // Reparo de Etapa — a saída para quando a AUTOMAÇÃO erra.
2
+ //
3
+ // O fluxo normal é `move`, e nele a Etapa só avança: essa regra existe porque
4
+ // board que retrocede sozinho vira board em que ninguém confia. Mas a automação
5
+ // erra. Uma corrida entre o `decompose-apply` e o `code-review` pôs duas Stories
6
+ // fundacionais e duas Tasks em 🎉 Done no instante em que nasceram, e o `move`
7
+ // recusou desfazer — a única saída foi mutação GraphQL crua no campo do Project.
8
+ //
9
+ // Isso é pior que ter o comando: quem consegue montar a mutação à mão consegue
10
+ // fazer qualquer coisa no board, sem confirmação e sem deixar rastro. Aqui o
11
+ // preço é explícito e o registro é obrigatório:
12
+ //
13
+ // • --yes e --reason são exigidos (sem os dois, recusa);
14
+ // • em terminal interativo, ainda pede a confirmação digitada;
15
+ // • cada reparo vira um comentário na issue dizendo quem, de onde e por quê.
16
+ //
17
+ // Não existe label nem workflow para isto de propósito: reparo é ato humano
18
+ // deliberado, não automação.
19
+ import * as p from '@clack/prompts';
20
+ import chalk from 'chalk';
21
+ import { resolveToken } from '../api/auth.mjs';
22
+ import { getIssue, commentOnIssue } from '../api/github-rest.mjs';
23
+ import { verifyTokenScopes } from '../api/auth.mjs';
24
+ import { detectIssueType } from '../lib/issue-type.mjs';
25
+ import { loadProjectConfig, resolveField, setItemStage, resolveStageName } from '../lib/board.mjs';
26
+ import { loadConfig } from '../lib/project-root.mjs';
27
+ import { resolveProgressName } from './move.mjs';
28
+ import { CONFIG_FILE } from '../config.mjs';
29
+
30
+ /**
31
+ * Lista de issues a reparar (função PURA).
32
+ *
33
+ * Aceita "529", "#529", "529,530" e "#529, #530" — o incidente que motivou o
34
+ * comando pegou sete itens de uma vez, e obrigar a sete invocações seria pedir
35
+ * para alguém desistir no meio.
36
+ *
37
+ * @param {string|number} input
38
+ * @returns {{ numbers: number[], error: string|null }}
39
+ */
40
+ export function parseIssueList(input) {
41
+ const raw = String(input ?? '').split(',').map(s => s.trim()).filter(Boolean);
42
+ if (raw.length === 0) return { numbers: [], error: 'Informe ao menos uma issue (ex.: 529 ou 529,530).' };
43
+ const numbers = [];
44
+ for (const item of raw) {
45
+ const n = parseInt(item.replace('#', ''), 10);
46
+ if (!Number.isInteger(n) || n <= 0) {
47
+ return { numbers: [], error: `Issue inválida: "${item}". Use o número da issue, ex.: 529 ou #529.` };
48
+ }
49
+ if (!numbers.includes(n)) numbers.push(n);
50
+ }
51
+ return { numbers, error: null };
52
+ }
53
+
54
+ /**
55
+ * O reparo pode prosseguir? (função PURA — é a trava do comando).
56
+ *
57
+ * Recusa sem `--yes` OU sem `--reason` com conteúdo. A mensagem diz o comando
58
+ * completo: quem chegou aqui está consertando um estrago e não deve ter que
59
+ * caçar a sintaxe.
60
+ *
61
+ * @param {{ yes?: boolean, reason?: string }} options
62
+ * @returns {{ ok: boolean, error: string|null }}
63
+ */
64
+ export function checkRepairGuards({ yes = false, reason = '' } = {}) {
65
+ const motivo = String(reason || '').trim();
66
+ if (!yes || !motivo) {
67
+ const faltando = [!yes ? '--yes' : null, !motivo ? '--reason "<motivo>"' : null].filter(Boolean);
68
+ return {
69
+ ok: false,
70
+ error:
71
+ `Reparo de Etapa exige ${faltando.join(' e ')}.\n` +
72
+ 'A Etapa só avança no fluxo normal — reparar é exceção, e fica registrado na issue.\n' +
73
+ 'Ex.: spec-wave repair-stage 529,530 ready --yes --reason "corrida do decompose-apply com o code-review"',
74
+ };
75
+ }
76
+ return { ok: true, error: null };
77
+ }
78
+
79
+ /**
80
+ * Comentário de auditoria do reparo (função PURA).
81
+ *
82
+ * Sem isto o reparo é indistinguível de uma mutação manual — e a razão de o
83
+ * comando existir é justamente deixar rastro.
84
+ */
85
+ export function renderRepairComment({ from, to, status, reason, actor, version }) {
86
+ return [
87
+ '🛠️ **Etapa reparada manualmente** (`spec-wave repair-stage`)',
88
+ '',
89
+ `- **De:** ${from || '(sem etapa)'}`,
90
+ `- **Para:** ${to}${status ? ` · Status **${status}**` : ''}`,
91
+ `- **Por:** ${actor ? `@${actor}` : '(usuário do token)'}`,
92
+ `- **Motivo:** ${String(reason).trim()}`,
93
+ '',
94
+ `_A Etapa não retrocede pelo fluxo normal; este reparo foi explícito${version ? ` · spec-wave ${version}` : ''}._`,
95
+ ].join('\n');
96
+ }
97
+
98
+ export async function repairStage(issuesArg, stageArg, options = {}) {
99
+ const { numbers, error: listError } = parseIssueList(issuesArg);
100
+ if (listError) {
101
+ p.log.error(listError);
102
+ process.exitCode = 1;
103
+ return;
104
+ }
105
+
106
+ const { stage, error: stageError } = resolveStageName(stageArg);
107
+ if (stageError) {
108
+ p.log.error(stageError);
109
+ process.exitCode = 1;
110
+ return;
111
+ }
112
+
113
+ // Sem --status o Status não é tocado: no reparo, mexer no que não foi pedido
114
+ // é justamente o que se está consertando.
115
+ let status = null;
116
+ if (options.status !== undefined && options.status !== null && String(options.status).trim() !== '') {
117
+ const resolved = resolveProgressName(options.status);
118
+ if (resolved.error) {
119
+ p.log.error(resolved.error);
120
+ process.exitCode = 1;
121
+ return;
122
+ }
123
+ status = resolved.status;
124
+ }
125
+
126
+ const guards = checkRepairGuards(options);
127
+ if (!guards.ok && !options.dryRun) {
128
+ p.log.error(guards.error);
129
+ process.exitCode = 1;
130
+ return;
131
+ }
132
+
133
+ const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
134
+ const { config, root } = loadConfig();
135
+ const owner = envOwner || config?.owner;
136
+ const repo = envRepo || config?.repo;
137
+ if (!owner || !repo) {
138
+ p.log.error(
139
+ 'Não foi possível determinar owner/repo.\n' +
140
+ `Rode dentro de um repositório com ${CONFIG_FILE} (\`spec-wave init\`) ou defina GITHUB_REPOSITORY=owner/repo.`
141
+ );
142
+ process.exitCode = 1;
143
+ return;
144
+ }
145
+
146
+ let token;
147
+ try {
148
+ token = await resolveToken();
149
+ } catch (err) {
150
+ p.log.error(err.message);
151
+ process.exitCode = 1;
152
+ return;
153
+ }
154
+
155
+ p.intro(chalk.bold(
156
+ `spec-wave repair-stage ${numbers.map(n => `#${n}`).join(', ')} → ${stage}${options.dryRun ? ' (dry-run)' : ''}`
157
+ ));
158
+
159
+ const { project, error: projectError } = loadProjectConfig({ cwd: root || process.cwd() });
160
+ if (projectError) {
161
+ p.log.error(`${projectError} — nada a reparar. Rode \`spec-wave init\` (ou \`spec-wave refresh --config\`).`);
162
+ process.exitCode = 1;
163
+ return;
164
+ }
165
+ const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
166
+ const statusField = status ? await resolveField(token, project, 'Status').catch(() => null) : null;
167
+
168
+ // Lê tudo ANTES de escrever qualquer coisa: reparo parcial por issue
169
+ // inexistente no meio da lista é o tipo de surpresa que ninguém quer aqui.
170
+ const alvos = [];
171
+ for (const n of numbers) {
172
+ try {
173
+ const issue = await getIssue(token, owner, repo, n);
174
+ alvos.push({ number: n, issue, type: detectIssueType(issue) });
175
+ } catch (err) {
176
+ p.log.error(`Não foi possível ler a issue #${n}: ${err.message}`);
177
+ process.exitCode = 1;
178
+ return;
179
+ }
180
+ }
181
+
182
+ for (const alvo of alvos) {
183
+ p.log.info(`#${alvo.number} ${alvo.type ? `(${alvo.type}) ` : ''}${alvo.issue.title}`);
184
+ }
185
+
186
+ if (options.dryRun) {
187
+ p.log.warn(
188
+ `Dry-run: ${alvos.length} item(ns) seriam movidos para "${stage}"` +
189
+ `${status ? ` / Status "${status}"` : ''}, com comentário de auditoria em cada issue.`
190
+ );
191
+ p.outro('Nada foi alterado.');
192
+ return;
193
+ }
194
+
195
+ // Confirmação digitada, além do --yes: em terminal, a chance de o comando ter
196
+ // vindo de um histórico ou de um copiar-colar é real.
197
+ if (process.stdin.isTTY) {
198
+ const esperado = `repair ${alvos.map(a => `#${a.number}`).join(' ')}`;
199
+ const digitado = await p.text({
200
+ message: `Confirme digitando: ${esperado}`,
201
+ placeholder: esperado,
202
+ });
203
+ if (p.isCancel(digitado) || String(digitado).trim() !== esperado) {
204
+ p.cancel('Reparo cancelado — nada foi alterado.');
205
+ process.exitCode = 1;
206
+ return;
207
+ }
208
+ }
209
+
210
+ // Login do token para o registro. Best-effort: falhar aqui não pode impedir o
211
+ // reparo, mas o comentário fica sem o "quem".
212
+ const actor = await verifyTokenScopes(token).then(i => i.login).catch(() => null);
213
+ const version = process.env.npm_package_version || null;
214
+
215
+ let reparados = 0;
216
+ for (const alvo of alvos) {
217
+ try {
218
+ const { from } = await setItemStage(
219
+ token, project, etapaField, statusField, alvo.issue.node_id, stage, status);
220
+ reparados += 1;
221
+ p.log.success(`#${alvo.number}: ${from || '(sem etapa)'} → ${chalk.bold(stage)}${status ? ` / ${status}` : ''}`);
222
+ await commentOnIssue(token, owner, repo, alvo.number, renderRepairComment({
223
+ from, to: stage, status, reason: options.reason, actor, version,
224
+ })).catch(err => p.log.warn(`#${alvo.number}: reparo aplicado, mas o registro falhou: ${err.message}`));
225
+ } catch (err) {
226
+ p.log.error(`#${alvo.number}: falha ao reparar — ${err.message}`);
227
+ process.exitCode = 1;
228
+ }
229
+ }
230
+
231
+ p.outro(`${chalk.green('✓')} ${reparados}/${alvos.length} item(ns) reparado(s) e registrado(s).`);
232
+ }
@@ -4,7 +4,7 @@ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
4
4
  import { homedir } from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { resolveToken } from '../api/auth.mjs';
7
- import { CONFIG_FILE, WORKFLOW_FILES, ISSUE_TEMPLATE_FILES, ALL_LABELS } from '../config.mjs';
7
+ import { CONFIG_FILE, WORKFLOW_FILES, ISSUE_TEMPLATE_FILES, ALL_LABELS, allLabelsFor } from '../config.mjs';
8
8
  import { getProjectSnapshot } from '../api/github-graphql.mjs';
9
9
  import {
10
10
  getFileContent, upsertFile, listLabels, createLabel, updateLabel, deleteLabel,
@@ -80,14 +80,22 @@ function applySkill(job) {
80
80
  * 0.13.0 mas ainda presente em todo repo inicializado com a 0.12.0. Só o
81
81
  * namespace `spec-wave:` é considerado: labels do time não são da nossa conta.
82
82
  *
83
+ * As labels de modelo (`spec-wave:model:<apelido>`) entram pelo `fileAi`: são
84
+ * derivadas de `ai.modelAliases`, e tratá-las como desconhecidas fazia o update
85
+ * APAGAR justamente o override de modelo por issue — o mecanismo que destrava
86
+ * uma Feature que não gera plano no modelo default. Passar o bloco `ai` também
87
+ * é o que faz o update CRIÁ-LAS, em vez de exigir `gh label create` à mão.
88
+ *
83
89
  * @param {Array<{name,color,description}>} existing labels do repo
90
+ * @param {object} [fileAi] bloco `ai` do .spec-wave.json
84
91
  * @returns {{ missing: object[], changed: object[], orphan: object[] }}
85
92
  */
86
- export function diffLabels(existing) {
93
+ export function diffLabels(existing, fileAi) {
94
+ const wanted = allLabelsFor(fileAi);
87
95
  const byName = new Map((existing || []).map(l => [l.name, l]));
88
96
  const missing = [];
89
97
  const changed = [];
90
- for (const label of ALL_LABELS) {
98
+ for (const label of wanted) {
91
99
  const cur = byName.get(label.name);
92
100
  if (!cur) {
93
101
  missing.push(label);
@@ -98,7 +106,7 @@ export function diffLabels(existing) {
98
106
  changed.push(label);
99
107
  }
100
108
  }
101
- const known = new Set(ALL_LABELS.map(l => l.name));
109
+ const known = new Set(wanted.map(l => l.name));
102
110
  const orphan = (existing || []).filter(l => l.name?.startsWith('spec-wave:') && !known.has(l.name));
103
111
  return { missing, changed, orphan };
104
112
  }
@@ -266,7 +274,7 @@ export async function update(options = {}) {
266
274
  if (remote === null) repoFiles.push({ ...f, reason: 'ausente', local });
267
275
  else if (remote !== local) repoFiles.push({ ...f, reason: 'desatualizado', local });
268
276
  }
269
- labelDiff = diffLabels(await listLabels(tk, owner, repo));
277
+ labelDiff = diffLabels(await listLabels(tk, owner, repo), config?.ai);
270
278
  repoChecked = true;
271
279
  s.stop('Repositório comparado.');
272
280
  } catch (err) {
package/src/config.mjs CHANGED
@@ -372,6 +372,49 @@ export const TRIGGER_LABELS = [
372
372
 
373
373
  export const ALL_LABELS = [...TYPE_LABELS, ...PRIORITY_LABELS, ...TRIGGER_LABELS];
374
374
 
375
+ /**
376
+ * Labels de override de modelo, derivadas de `ai.modelAliases` (função PURA).
377
+ *
378
+ * Estas NÃO cabem em ALL_LABELS: o conjunto depende do .spec-wave.json de cada
379
+ * repo. Tratá-las como estáticas produziu dois defeitos simétricos:
380
+ *
381
+ * • o `update` (que remove toda `spec-wave:*` fora da lista conhecida) e o
382
+ * `doctor` acusavam TODO alias configurado como label descontinuada, com o
383
+ * conselho de apagá-la — apagar quebra o override de modelo por issue, que é
384
+ * justamente o que destrava uma Feature que não gera plano no modelo default;
385
+ * • o `init`/`update` nunca as criavam, então usar o mecanismo exigia
386
+ * `gh label create` à mão, um por alias.
387
+ *
388
+ * É a mesma armadilha que já tinha apagado a `spec-wave:dev-agent` (ver
389
+ * LABEL_DEV_AGENT), com um agravante: ali bastou registrar a label na lista, e
390
+ * aqui a lista não pode ser fixa.
391
+ *
392
+ * @param {object} [modelAliases] bloco `ai.modelAliases` do .spec-wave.json
393
+ * @returns {Array<{name: string, color: string, description: string}>}
394
+ */
395
+ export function modelLabels(modelAliases) {
396
+ if (!modelAliases || typeof modelAliases !== 'object') return [];
397
+ return Object.entries(modelAliases)
398
+ .filter(([alias, model]) => alias.trim() && typeof model === 'string' && model.trim())
399
+ .map(([alias, model]) => ({
400
+ name: `${MODEL_LABEL_PREFIX}${alias}`,
401
+ color: '5319E7',
402
+ description: `Gera esta issue com ${model.trim()}`,
403
+ }));
404
+ }
405
+
406
+ /**
407
+ * Labels do fluxo + as de modelo deste repo (função PURA).
408
+ *
409
+ * É este o conjunto que `update`, `doctor` e `init` devem usar — ALL_LABELS
410
+ * sozinha é incompleta em qualquer repo que configure `ai.modelAliases`.
411
+ *
412
+ * @param {object} [fileAi] bloco `ai` do .spec-wave.json
413
+ */
414
+ export function allLabelsFor(fileAi) {
415
+ return [...ALL_LABELS, ...modelLabels(fileAi?.modelAliases)];
416
+ }
417
+
375
418
  /**
376
419
  * Nomes das labels de uma issue (função PURA).
377
420
  *
package/src/lib/board.mjs CHANGED
@@ -147,6 +147,50 @@ export async function advanceToStage(token, project, etapaField, statusField, no
147
147
  return true;
148
148
  }
149
149
 
150
+ /**
151
+ * Escreve a Etapa IGNORANDO a regra de avanço — reparo, não fluxo.
152
+ *
153
+ * `advanceToStage` continua sendo o único caminho do fluxo, e continua sem
154
+ * escape hatch: a Etapa só avança. Esta função existe porque a automação erra —
155
+ * uma corrida entre o `decompose-apply` e o `code-review` já pôs duas Stories e
156
+ * duas Tasks em 🎉 Done no nascimento — e a única saída era mutação GraphQL
157
+ * crua: sem trava, sem confirmação e sem rastro. Um caminho com as três é
158
+ * melhor que a mão livre.
159
+ *
160
+ * Quem chama (`repair-stage`) é responsável pela confirmação e pelo registro na
161
+ * issue. Aqui só se escreve.
162
+ *
163
+ * @param {string} token token com scope project
164
+ * @param {object} project bloco project (precisa de .id)
165
+ * @param {{id,options}|null} etapaField campo "Etapa" (ver resolveField)
166
+ * @param {{id,options}|null} statusField campo nativo "Status"
167
+ * @param {string} nodeId node id da issue
168
+ * @param {string} targetStage etapa de destino (precisa existir em STAGE_ORDER)
169
+ * @param {string} [targetStatus] valor do Status; omitido = não mexe no Status
170
+ * @returns {Promise<{ from: string|null }>} etapa em que o item estava
171
+ */
172
+ export async function setItemStage(token, project, etapaField, statusField, nodeId, targetStage, targetStatus) {
173
+ if (!etapaField?.id) throw new Error('Campo "Etapa" não encontrado no Project — nada a reparar.');
174
+ if (STAGE_ORDER.indexOf(targetStage) === -1) {
175
+ throw new Error(`Etapa "${targetStage}" não faz parte do fluxo (${STAGE_ORDER.join(' → ')}).`);
176
+ }
177
+ const optionId = etapaField.options?.[targetStage];
178
+ if (!optionId) {
179
+ throw new Error(
180
+ `A opção "${targetStage}" não existe no campo Etapa deste Project. ` +
181
+ 'Rode `spec-wave refresh --config` para sincronizar os ids.'
182
+ );
183
+ }
184
+ const itemId = await addProjectItem(token, project.id, nodeId);
185
+ const from = await getItemSingleSelectValue(token, itemId, etapaField.id).catch(() => null);
186
+ await setItemSingleSelect(token, project.id, itemId, etapaField.id, optionId);
187
+ if (statusField?.id && targetStatus) {
188
+ const statusOption = statusField.options?.[targetStatus];
189
+ if (statusOption) await setItemSingleSelect(token, project.id, itemId, statusField.id, statusOption);
190
+ }
191
+ return { from };
192
+ }
193
+
150
194
  /**
151
195
  * Define APENAS o Status nativo (Todo/In Progress/Done) de um item, sem tocar
152
196
  * na Etapa — usado para marcar progresso dentro da etapa atual.
@@ -6,7 +6,7 @@ import {
6
6
  } from '../config.mjs';
7
7
  import { lintLanguage } from './output-lint.mjs';
8
8
  import { runAgent } from '../agent/index.mjs';
9
- import { TruncatedOutputError, isTruncationReason } from '../agent/errors.mjs';
9
+ import { TruncatedOutputError, MaxTurnsError, isTruncationReason } from '../agent/errors.mjs';
10
10
  import { computeCost } from './usage-report.mjs';
11
11
  import { findConfigPath } from './project-root.mjs';
12
12
 
@@ -17,12 +17,21 @@ import { findConfigPath } from './project-root.mjs';
17
17
  // branco. Ajustável por `ai.maxTokens` / `ai.maxTokensByAction`.
18
18
  export const DEFAULT_MAX_TOKENS = 32768;
19
19
 
20
- // Teto padrão POR AÇÃO, aplicado abaixo de `ai.maxTokens` na precedência. A
21
- // crítica devolve uma lista JSON de findings, muito menor que um spec/plan —
22
- // 8192 é folgado para ela. Antes esse valor era passado hard-coded pelo
23
- // critique.mjs, o que sobrepunha `ai.maxTokensByAction.critique` e tornava o
24
- // knob morto; aqui ele é um DEFAULT, então a configuração volta a valer.
25
- export const MAX_TOKENS_BY_ACTION = { critique: 8192 };
20
+ // Teto padrão POR AÇÃO, aplicado abaixo de `ai.maxTokens` na precedência.
21
+ //
22
+ // A crítica TEVE um default próprio de 8192, com o raciocínio de que a saída
23
+ // (uma lista JSON de findings) é muito menor que um spec/plan. O raciocínio
24
+ // estava certo sobre a SAÍDA e errado sobre o CUSTO: em modelo que raciocina por
25
+ // padrão, o pensamento consome o mesmo teto antes de a primeira linha da
26
+ // resposta sair. O corte vira TruncatedOutputError (os dois backends falham
27
+ // alto, e isso está certo) — mas o efeito prático é a crítica não concluir numa
28
+ // etapa cujo propósito é justamente barrar problema, e a rodada se perde.
29
+ //
30
+ // `max_tokens` é TETO, não consumo: deixar a crítica herdar DEFAULT_MAX_TOKENS
31
+ // não encarece nada em modelo sem raciocínio, e é o que evita o corte no que
32
+ // raciocina. A tabela fica aqui, vazia, para o caso de alguma ação futura
33
+ // precisar mesmo de um default próprio.
34
+ export const MAX_TOKENS_BY_ACTION = {};
26
35
 
27
36
  /**
28
37
  * Resolve o alias de modelo vindo de label (função PURA).
@@ -143,6 +152,27 @@ function resolveAi(action, { labels = [], modelOverride } = {}) {
143
152
  return resolveAiConfig({ env: process.env, fileAi: readFileAi(), action, labels, modelOverride });
144
153
  }
145
154
 
155
+ /**
156
+ * Avisa quando a label de modelo existe na issue mas não resolve.
157
+ *
158
+ * `resolveModelLabel` já devolvia o erro, e NINGUÉM o lia: um apelido errado
159
+ * fazia a label ser ignorada em silêncio, a precedência caía para o modelo do
160
+ * .spec-wave.json e o run seguia com o modelo errado. Quem aplicou a label para
161
+ * reprocessar num modelo mais forte não tinha como perceber — o único sinal era
162
+ * o `origem:` da linha acima, que ninguém compara.
163
+ */
164
+ function warnUnresolvedModelLabel(ai) {
165
+ const alias = ai.labelAlias;
166
+ if (!alias?.error) return;
167
+ const motivo = alias.error === 'ambíguo'
168
+ ? `há mais de uma label ${MODEL_LABEL_PREFIX}* na issue (${alias.alias})`
169
+ : `o apelido "${alias.alias}" não existe em ai.modelAliases do .spec-wave.json`;
170
+ console.warn(
171
+ `⚠️ Label de modelo IGNORADA: ${motivo}. ` +
172
+ `Seguindo com ${ai.model} (origem: ${ai.modelSource}).`
173
+ );
174
+ }
175
+
146
176
  // Retry de falha transitória do provedor. Sem isto, um corpo cortado numa
147
177
  // geração de 90s derruba o Action inteiro e a issue fica sem spec — o custo de
148
178
  // esperar alguns segundos é irrisório perto de refazer o ciclo à mão.
@@ -186,7 +216,7 @@ export async function withRetry(label, fn, { attempts = RETRY_ATTEMPTS, baseMs =
186
216
  // `agent/errors.mjs` quando os backends portados passaram a precisar deles.
187
217
  // Re-exportados para não quebrar os 5 consumidores e os testes que importam
188
218
  // deste módulo.
189
- export { TruncatedOutputError, isTruncationReason };
219
+ export { TruncatedOutputError, MaxTurnsError, isTruncationReason };
190
220
 
191
221
  // Os parâmetros de sampling foram REMOVIDOS a partir do Claude Opus 4.7 (vale
192
222
  // para 4.8 e 5, Sonnet 5, Fable 5 e Mythos 5): enviar temperature/top_p/top_k
@@ -247,6 +277,7 @@ export async function generateDocument(systemPrompt, userContent, opts = {}) {
247
277
  `temperature: ${sendTemperature ? temperature : 'n/a (removida neste modelo)'} · ` +
248
278
  `max_tokens: ${maxTokens}`
249
279
  );
280
+ warnUnresolvedModelLabel(ai);
250
281
 
251
282
  // Acumuladores de uso desta invocação (1 ou 2 chamadas, com o retry de lint).
252
283
  let inputTokens = 0;
@@ -262,6 +293,11 @@ export async function generateDocument(systemPrompt, userContent, opts = {}) {
262
293
  // repositório antes de escrever o documento. É o que os blocos
263
294
  // `requires-tools` dos model-prompt sempre descreveram.
264
295
  tools: opts.tools ?? ['Read', 'Glob', 'Grep'],
296
+ // Teto de turnos do PROMPT (frontmatter `maxTurns`, default
297
+ // DEFAULT_PROMPT_MAX_TURNS). Sem repassar aqui, o valor declarado no
298
+ // prompt nunca chegava ao motor e o MAX_TURNS dos backends prevalecia —
299
+ // um teto ficava documentado num lugar e vigorando em outro.
300
+ maxTurns: opts.maxTurns,
265
301
  };
266
302
  const { text, usage } = await withRetry(`Geração via ${ai.provider}`, () =>
267
303
  generateViaEngine(system, userContent, ai, callOpts));
@@ -362,6 +398,7 @@ export async function generateStructured(systemPrompt, userContent, opts = {}) {
362
398
  `Provider de IA: ${ai.provider} · modelo: ${ai.model} (origem: ${ai.modelSource}) · ` +
363
399
  `saída estruturada: ${schema.name}${strict ? ' (strict)' : ''} · max_tokens: ${maxTokens}`
364
400
  );
401
+ warnUnresolvedModelLabel(ai);
365
402
 
366
403
  const callOpts = {
367
404
  temperature: sendTemperature ? temperature : undefined,
@@ -451,7 +488,7 @@ export function extractOpenRouterUsage(usage) {
451
488
  * @returns {Promise<{text: string, json?: object, usage: object}>}
452
489
  */
453
490
  async function generateViaEngine(
454
- systemPrompt, userContent, ai, { temperature, maxTokens, schema, strict, action, tools },
491
+ systemPrompt, userContent, ai, { temperature, maxTokens, maxTurns, schema, strict, action, tools },
455
492
  ) {
456
493
  const result = await runAgent(userContent, {
457
494
  provider: ai.provider,
@@ -463,6 +500,7 @@ async function generateViaEngine(
463
500
  userId: 'spec-wave',
464
501
  ...(tools ? { tools } : {}),
465
502
  ...(maxTokens ? { maxTokens } : {}),
503
+ ...(maxTurns ? { maxTurns } : {}),
466
504
  ...(temperature !== undefined ? { temperature } : {}),
467
505
  ...(schema
468
506
  ? { responseSchema: { ...schema, strict: strict === true } }
@@ -492,6 +530,18 @@ async function generateViaEngine(
492
530
 
493
531
  const text = stripReasoning(result.outputText || '');
494
532
  if (!text) {
533
+ // Teto de turnos tem causa e remédio próprios, e NÃO é transitório: o
534
+ // modelo gastou todos os turnos em tool calls sem nunca escrever o
535
+ // documento, e repetir reproduz isso. Ver MaxTurnsError.
536
+ if (result.resultSubtype === 'error_max_turns') {
537
+ throw new MaxTurnsError({
538
+ provider: ai.provider,
539
+ model: ai.model,
540
+ turns: result.numTurns,
541
+ action: action || null,
542
+ toolCalls: result.toolCalls || [],
543
+ });
544
+ }
495
545
  const err = new Error(
496
546
  `O provider ${ai.provider} retornou resposta sem texto ` +
497
547
  `(subtype=${result.resultSubtype}, turnos=${result.numTurns}).`