@spec-wave/cli 0.10.0 → 0.11.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.
@@ -5,6 +5,7 @@ import { getIssue, removeLabel, commentOnIssue } from '../api/github-rest.mjs';
5
5
  import { generateDocument } from '../lib/claude.mjs';
6
6
  import { recordUsage } from '../lib/usage-report.mjs';
7
7
  import { slugify } from '../lib/slugify.mjs';
8
+ import { isForced, consumeForceLabel } from '../lib/force.mjs';
8
9
  import { detectIssueType } from '../lib/issue-type.mjs';
9
10
  import { allowsSpecPlan, SPEC_PLAN_EXCLUDED_TYPES, TARGET_LANGUAGE } from '../config.mjs';
10
11
 
@@ -41,7 +42,7 @@ Regras:
41
42
  - Seja específico e detalhado em cada seção.
42
43
  - Responda APENAS com o conteúdo do spec.md, sem texto adicional.`;
43
44
 
44
- export async function generateSpec({ issueNumber }) {
45
+ export async function generateSpec({ issueNumber, force = false }) {
45
46
  const token = await resolveToken();
46
47
  const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/');
47
48
 
@@ -70,6 +71,13 @@ export async function generateSpec({ issueNumber }) {
70
71
  return;
71
72
  }
72
73
 
74
+ // A geração de spec/plan não tem guard — re-acionar a label já regera e
75
+ // sobrescreve o arquivo. O force existe por simetria com o decompose (e para
76
+ // a label ser consumida quando aplicada aos três de uma vez).
77
+ const forced = isForced({ labels: issue.labels || [], flag: force });
78
+ await consumeForceLabel(token, owner, repo, parseInt(issueNumber, 10));
79
+ if (forced) console.log('Modo forçado ativo — spec.md será regerado (sobrescreve o existente).');
80
+
73
81
  const slug = slugify(issue.title);
74
82
  const featureDir = `docs/features/${slug}`;
75
83
  const filePath = `${featureDir}/spec.md`;
@@ -125,6 +133,21 @@ export async function generateSpec({ issueNumber }) {
125
133
  );
126
134
 
127
135
  console.log(`spec.md criado em: ${filePath}`);
136
+ } catch (err) {
137
+ // Sem isto a label de gatilho fica aplicada — e como o workflow dispara em
138
+ // `issues: [labeled]`, re-adicionar uma label já presente não emite evento:
139
+ // a issue vira um beco sem saída, sem comentário e sem sinal no board.
140
+ // Espelha o tratamento do tipo não suportado (acima): remove o gatilho e
141
+ // reporta na issue. O erro segue propagando para o Action falhar visível.
142
+ await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:spec').catch(() => {});
143
+ await commentOnIssue(
144
+ token, owner, repo, parseInt(issueNumber, 10),
145
+ `❌ **Falha ao gerar a spec.md**\n\n` +
146
+ `\`\`\`\n${err.message}\n\`\`\`\n\n` +
147
+ `A label \`spec-wave:spec\` foi removida para destravar o gatilho — ` +
148
+ `adicione-a de novo para tentar outra vez.`
149
+ ).catch(() => {});
150
+ throw err;
128
151
  } finally {
129
152
  // Best-effort: nunca propaga erro (ver recordUsage).
130
153
  await recordUsage({ token, owner, repo, issueNumber: parseInt(issueNumber, 10), entries: usageEntries });
@@ -4,6 +4,7 @@ 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
6
  import { CONFIG_FILE, LABEL_CRITIQUE_FAILED, REQUIRED_PLAN_SECTIONS, REQUIRED_SPEC_SECTIONS } from '../config.mjs';
7
+ import { findIncompleteDocSigns } from '../lib/doc-completeness.mjs';
7
8
 
8
9
  export async function validate({ issueNumber }) {
9
10
  const token = await resolveToken();
@@ -49,6 +50,9 @@ export async function validate({ issueNumber }) {
49
50
  errors.push(`❌ Seção obrigatória ausente no plan.md: **${section}**`);
50
51
  }
51
52
  }
53
+ for (const problem of findIncompleteDocSigns(planContent)) {
54
+ errors.push(`❌ \`plan.md\` parece incompleto: ${problem}`);
55
+ }
52
56
  }
53
57
 
54
58
  // Check spec.md
@@ -62,6 +66,11 @@ export async function validate({ issueNumber }) {
62
66
  errors.push(`❌ Seção obrigatória ausente no spec.md: **${section}**`);
63
67
  }
64
68
  }
69
+ // Seções presentes não garantem documento completo: um corte dentro da
70
+ // última seção passa na checagem acima (foi o caso da EP2-F13).
71
+ for (const problem of findIncompleteDocSigns(specContent)) {
72
+ errors.push(`❌ \`spec.md\` parece incompleto: ${problem}`);
73
+ }
65
74
  }
66
75
 
67
76
  // Remove trigger label
package/src/config.mjs CHANGED
@@ -183,6 +183,10 @@ export const PRIORITY_LABELS = [
183
183
  // Labels de estado gravadas pelas automações (não são gatilhos do usuário).
184
184
  export const LABEL_CRITIQUE_FAILED = 'spec-wave:critique-failed';
185
185
  export const LABEL_DECOMPOSED = 'spec-wave:decomposed';
186
+ // Modificador (não é gatilho — sozinha não dispara workflow nenhum): quando
187
+ // presente junto de uma label de gatilho, manda o comando re-executar a etapa
188
+ // ignorando os guards. É consumida (removida) pelo run que a leu.
189
+ export const LABEL_FORCE = 'spec-wave:force';
186
190
 
187
191
  export const TRIGGER_LABELS = [
188
192
  { name: 'spec-wave:spec', color: 'BFD4F2', description: 'Gerar spec.md via GitHub Action' },
@@ -192,6 +196,7 @@ export const TRIGGER_LABELS = [
192
196
  { name: 'spec-wave:decompose', color: 'BFD4F2', description: 'Decompor em Stories e Tasks' },
193
197
  { name: LABEL_CRITIQUE_FAILED, color: 'B60205', description: 'Crítica adversarial apontou contradições graves' },
194
198
  { name: LABEL_DECOMPOSED, color: 'EDEDED', description: 'Feature já decomposta em Stories e Tasks' },
199
+ { name: LABEL_FORCE, color: 'D93F0B', description: 'Re-executa a etapa ignorando os guards (consumida no run)' },
195
200
  ];
196
201
 
197
202
  export const ALL_LABELS = [...TYPE_LABELS, ...PRIORITY_LABELS, ...TRIGGER_LABELS];
@@ -5,19 +5,30 @@ import { CONFIG_FILE, getProvider, DEFAULT_PROVIDER } from '../config.mjs';
5
5
  import { lintLanguage } from './output-lint.mjs';
6
6
  import { computeCost } from './usage-report.mjs';
7
7
 
8
+ // Teto de saída padrão. O valor anterior (8192) truncava specs reais: um
9
+ // documento de ~20 KB em pt-BR já custa ~6k tokens, e modelos com raciocínio
10
+ // (Opus 4.7+, deepseek-r1) gastam o restante "pensando" ANTES de escrever. O
11
+ // teto de saída dos modelos atuais é 128k — 32k deixa folga sem virar cheque em
12
+ // branco. Ajustável por `ai.maxTokens` / `ai.maxTokensByAction`.
13
+ export const DEFAULT_MAX_TOKENS = 32768;
14
+
8
15
  /**
9
- * Resolve provider/modelo de IA (função PURA — testável sem process.env nem fs).
16
+ * Resolve provider/modelo/teto de tokens de IA (função PURA — testável sem
17
+ * process.env nem fs).
10
18
  *
11
19
  * Precedência do modelo: env.SPEC_WAVE_MODEL → fileAi.models[action] →
12
20
  * fileAi.model → default do provider. Provider: env.SPEC_WAVE_PROVIDER →
13
21
  * fileAi.provider → default. Assim uma ação específica (ex.: critique) pode
14
22
  * usar modelo próprio via bloco `ai.models` do .spec-wave.json.
15
23
  *
24
+ * maxTokens segue a mesma forma: env.SPEC_WAVE_MAX_TOKENS →
25
+ * fileAi.maxTokensByAction[action] → fileAi.maxTokens → DEFAULT_MAX_TOKENS.
26
+ *
16
27
  * @param {object} params
17
28
  * @param {object} [params.env] objeto tipo process.env
18
29
  * @param {object} [params.fileAi] bloco `ai` do .spec-wave.json
19
30
  * @param {string} [params.action] ação de IA (ver AI_ACTIONS em config.mjs)
20
- * @returns {{ provider: string, model: string, secret: string, pricing: object|null }}
31
+ * @returns {{ provider: string, model: string, secret: string, pricing: object|null, maxTokens: number }}
21
32
  */
22
33
  export function resolveAiConfig({ env = {}, fileAi = {}, action } = {}) {
23
34
  const provider = (env.SPEC_WAVE_PROVIDER || fileAi.provider || DEFAULT_PROVIDER).toLowerCase();
@@ -29,7 +40,24 @@ export function resolveAiConfig({ env = {}, fileAi = {}, action } = {}) {
29
40
  // pricing: tabela `ai.pricing` do .spec-wave.json ({ [model]: { input,
30
41
  // output } } em USD/1M tokens) — usada para estimar custo quando o provider
31
42
  // não devolve o valor (Anthropic).
32
- return { provider: meta.value, model, secret: meta.secret, pricing: fileAi.pricing || null };
43
+ const maxTokens = positiveInt(env.SPEC_WAVE_MAX_TOKENS)
44
+ ?? (action ? positiveInt(fileAi.maxTokensByAction?.[action]) : undefined)
45
+ ?? positiveInt(fileAi.maxTokens)
46
+ ?? DEFAULT_MAX_TOKENS;
47
+ return {
48
+ provider: meta.value,
49
+ model,
50
+ secret: meta.secret,
51
+ pricing: fileAi.pricing || null,
52
+ maxTokens,
53
+ };
54
+ }
55
+
56
+ // Config vinda de JSON/env pode trazer string, zero ou lixo — só aceita inteiro
57
+ // positivo, senão cai na próxima fonte da precedência.
58
+ function positiveInt(value) {
59
+ const n = Number(value);
60
+ return Number.isInteger(n) && n > 0 ? n : undefined;
33
61
  }
34
62
 
35
63
  // Resolve o provider/modelo de IA a partir do .spec-wave.json (gravado pelo init
@@ -49,6 +77,87 @@ function resolveAi(action) {
49
77
  return resolveAiConfig({ env: process.env, fileAi, action });
50
78
  }
51
79
 
80
+ // Retry de falha transitória do provedor. Sem isto, um corpo cortado numa
81
+ // geração de 90s derruba o Action inteiro e a issue fica sem spec — o custo de
82
+ // esperar alguns segundos é irrisório perto de refazer o ciclo à mão.
83
+ export const RETRY_ATTEMPTS = 3;
84
+ const RETRY_BASE_MS = 2000;
85
+
86
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
87
+
88
+ // Transitório = vale a pena repetir a MESMA requisição. Erro de configuração
89
+ // (401, 400, chave ausente) não entra aqui: repetir só atrasa a mensagem útil.
90
+ export function isTransientProviderError(err) {
91
+ if (!err) return false;
92
+ if (err.transient) return true; // marcado na origem (corpo inválido/vazio)
93
+ const status = err.status ?? err.statusCode;
94
+ if (status === 429 || (status >= 500 && status < 600)) return true;
95
+ // undici/fetch: falha de rede vem como TypeError com cause
96
+ return /fetch failed|ECONNRESET|ETIMEDOUT|EAI_AGAIN|socket hang up|network|aborted/i
97
+ .test(err.message || '');
98
+ }
99
+
100
+ export async function withRetry(label, fn, { attempts = RETRY_ATTEMPTS, baseMs = RETRY_BASE_MS } = {}) {
101
+ let lastErr;
102
+ for (let attempt = 1; attempt <= attempts; attempt++) {
103
+ try {
104
+ return await fn();
105
+ } catch (err) {
106
+ lastErr = err;
107
+ if (attempt === attempts || !isTransientProviderError(err)) break;
108
+ const delayMs = baseMs * 2 ** (attempt - 1); // 2s, 4s, 8s…
109
+ console.warn(
110
+ `${label}: falha transitória na tentativa ${attempt}/${attempts} (${err.message}) — ` +
111
+ `repetindo em ${delayMs / 1000}s.`
112
+ );
113
+ await sleep(delayMs);
114
+ }
115
+ }
116
+ throw lastErr;
117
+ }
118
+
119
+ // Truncamento: os dois provedores dizem explicitamente que cortaram a saída no
120
+ // teto de tokens — OpenRouter (formato OpenAI) em `choices[0].finish_reason`,
121
+ // Anthropic em `message.stop_reason`. Ignorar esse campo é o que produzia um
122
+ // documento cortado no meio de uma frase, commitado como se estivesse completo.
123
+ const TRUNCATION_REASONS = new Set(['length', 'max_tokens']);
124
+
125
+ /** Motivo de parada indica saída cortada no teto de tokens? (função PURA) */
126
+ export function isTruncationReason(reason) {
127
+ return TRUNCATION_REASONS.has(reason);
128
+ }
129
+
130
+ // Repetir a MESMA requisição depois de truncar dá o mesmo corte — só sobe o
131
+ // custo. Por isso NÃO é marcado como transitório: o erro sobe, o Action falha
132
+ // visível, destrava a label e comenta na issue o que ajustar.
133
+ export class TruncatedOutputError extends Error {
134
+ constructor({ provider, model, maxTokens, reason, chars }) {
135
+ super(
136
+ `Saída truncada pelo teto de tokens (${provider} · ${model} · max_tokens=${maxTokens} · ` +
137
+ `motivo=${reason}). Foram gerados ~${chars} caracteres antes do corte. ` +
138
+ 'Aumente `ai.maxTokens` (ou `ai.maxTokensByAction`) no .spec-wave.json, ou reduza o ' +
139
+ 'tamanho da issue de origem. O documento NÃO foi gravado — um documento cortado ' +
140
+ 'passaria na validação de seções e valeria menos que nenhum.'
141
+ );
142
+ this.name = 'TruncatedOutputError';
143
+ this.truncated = true;
144
+ }
145
+ }
146
+
147
+ // Os parâmetros de sampling foram REMOVIDOS a partir do Claude Opus 4.7 (vale
148
+ // para 4.8 e 5, Sonnet 5, Fable 5 e Mythos 5): enviar temperature/top_p/top_k
149
+ // devolve 400. A OpenRouter hoje normaliza e não quebra, mas o caminho direto
150
+ // da Anthropic quebraria em toda requisição — daí a checagem no modelo, não no
151
+ // provider. Aceita tanto o id primário (`claude-opus-4-8`) quanto o slug da
152
+ // OpenRouter (`anthropic/claude-opus-4.8`).
153
+ const MODELS_WITHOUT_SAMPLING_PARAMS =
154
+ /claude-(?:opus-(?:4[.-]7|4[.-]8|5)|sonnet-5|fable-5|mythos-5)/i;
155
+
156
+ /** O modelo ainda aceita `temperature`? (função PURA) */
157
+ export function supportsTemperature(model) {
158
+ return !MODELS_WITHOUT_SAMPLING_PARAMS.test(model || '');
159
+ }
160
+
52
161
  // temperature padrão 0.2 (RFC-002 §5): "Determinism over Creativity". Pode ser
53
162
  // sobrescrita por chamada via opts, mas o default cobre spec/plan/decompose.
54
163
  //
@@ -66,10 +175,16 @@ function resolveAi(action) {
66
175
  export async function generateDocument(systemPrompt, userContent, opts = {}) {
67
176
  const ai = resolveAi(opts.action);
68
177
  const temperature = opts.temperature ?? 0.2;
69
- // Modelos de reasoning (ex.: deepseek-r1) consomem tokens "pensando" antes da
70
- // resposta, então o teto precisa ser maior para o plano não vir truncado.
71
- const maxTokens = opts.maxTokens ?? 8192;
72
- console.log(`Provider de IA: ${ai.provider} · modelo: ${ai.model} · temperature: ${temperature} · max_tokens: ${maxTokens}`);
178
+ // Modelos de reasoning (ex.: deepseek-r1, Opus 4.7+) consomem tokens
179
+ // "pensando" antes da resposta, então o teto precisa cobrir raciocínio +
180
+ // documento ver DEFAULT_MAX_TOKENS.
181
+ const maxTokens = opts.maxTokens ?? ai.maxTokens;
182
+ const sendTemperature = supportsTemperature(ai.model);
183
+ console.log(
184
+ `Provider de IA: ${ai.provider} · modelo: ${ai.model} · ` +
185
+ `temperature: ${sendTemperature ? temperature : 'n/a (removida neste modelo)'} · ` +
186
+ `max_tokens: ${maxTokens}`
187
+ );
73
188
 
74
189
  // Acumuladores de uso desta invocação (1 ou 2 chamadas, com o retry de lint).
75
190
  let inputTokens = 0;
@@ -77,9 +192,14 @@ export async function generateDocument(systemPrompt, userContent, opts = {}) {
77
192
  let cost = null; // soma dos custos conhecidos; se todos null → null
78
193
 
79
194
  const generate = async (system) => {
80
- const { text, usage } = ai.provider === 'openrouter'
81
- ? await generateWithOpenRouter(system, userContent, ai, temperature, maxTokens)
82
- : await generateWithAnthropic(system, userContent, ai, temperature, maxTokens);
195
+ const callOpts = {
196
+ temperature: sendTemperature ? temperature : undefined,
197
+ maxTokens,
198
+ };
199
+ const { text, usage } = await withRetry(`Geração via ${ai.provider}`, () =>
200
+ ai.provider === 'openrouter'
201
+ ? generateWithOpenRouter(system, userContent, ai, callOpts)
202
+ : generateWithAnthropic(system, userContent, ai, callOpts));
83
203
  inputTokens += usage.inputTokens;
84
204
  outputTokens += usage.outputTokens;
85
205
  if (typeof usage.cost === 'number') cost = (cost ?? 0) + usage.cost;
@@ -179,7 +299,7 @@ export function extractOpenRouterUsage(usage) {
179
299
  };
180
300
  }
181
301
 
182
- async function generateWithAnthropic(systemPrompt, userContent, ai, temperature, maxTokens) {
302
+ async function generateWithAnthropic(systemPrompt, userContent, ai, { temperature, maxTokens }) {
183
303
  const apiKey = process.env.ANTHROPIC_API_KEY;
184
304
  if (!apiKey) {
185
305
  throw new Error(
@@ -192,15 +312,56 @@ async function generateWithAnthropic(systemPrompt, userContent, ai, temperature,
192
312
  const message = await client.messages.create({
193
313
  model: ai.model,
194
314
  max_tokens: maxTokens,
195
- temperature,
315
+ // Omitida nos modelos que removeram sampling (Opus 4.7+, Sonnet 5, Fable 5):
316
+ // enviá-la devolve 400.
317
+ ...(temperature === undefined ? {} : { temperature }),
196
318
  messages: [{ role: 'user', content: userContent }],
197
319
  system: systemPrompt,
198
320
  });
199
321
 
200
- return { text: message.content[0].text, usage: extractAnthropicUsage(message.usage) };
322
+ // A resposta nem sempre começa com um bloco de texto (recusa, resposta vazia):
323
+ // `content[0].text` cru virava TypeError com mensagem inútil.
324
+ const text = (message.content || [])
325
+ .filter((block) => block.type === 'text')
326
+ .map((block) => block.text)
327
+ .join('');
328
+
329
+ if (message.stop_reason === 'refusal') {
330
+ throw new Error(
331
+ `A Anthropic recusou a requisição (stop_reason=refusal` +
332
+ `${message.stop_details?.category ? `, categoria=${message.stop_details.category}` : ''}). ` +
333
+ 'Revise o conteúdo da issue de origem.'
334
+ );
335
+ }
336
+ if (message.stop_reason === 'model_context_window_exceeded') {
337
+ // Estouro na ENTRADA — subir max_tokens não resolve; o que precisa encolher
338
+ // é a issue/contexto enviado.
339
+ throw new Error(
340
+ `Contexto de entrada excedido (${ai.model}). Reduza o tamanho da issue de origem ` +
341
+ 'ou do tech_context antes de repetir.'
342
+ );
343
+ }
344
+ if (isTruncationReason(message.stop_reason)) {
345
+ throw new TruncatedOutputError({
346
+ provider: 'anthropic',
347
+ model: ai.model,
348
+ maxTokens,
349
+ reason: message.stop_reason,
350
+ chars: text.length,
351
+ });
352
+ }
353
+ if (!text) {
354
+ const err = new Error(
355
+ `A Anthropic retornou resposta sem texto (stop_reason=${message.stop_reason}).`
356
+ );
357
+ err.transient = true;
358
+ throw err;
359
+ }
360
+
361
+ return { text, usage: extractAnthropicUsage(message.usage) };
201
362
  }
202
363
 
203
- async function generateWithOpenRouter(systemPrompt, userContent, ai, temperature, maxTokens) {
364
+ async function generateWithOpenRouter(systemPrompt, userContent, ai, { temperature, maxTokens }) {
204
365
  const apiKey = process.env.OPENROUTER_API_KEY;
205
366
  if (!apiKey) {
206
367
  throw new Error(
@@ -220,7 +381,8 @@ async function generateWithOpenRouter(systemPrompt, userContent, ai, temperature
220
381
  body: JSON.stringify({
221
382
  model: ai.model,
222
383
  max_tokens: maxTokens,
223
- temperature,
384
+ // Omitida nos modelos que removeram sampling (Opus 4.7+, Sonnet 5, Fable 5).
385
+ ...(temperature === undefined ? {} : { temperature }),
224
386
  messages: [
225
387
  { role: 'system', content: systemPrompt },
226
388
  { role: 'user', content: userContent },
@@ -232,13 +394,48 @@ async function generateWithOpenRouter(systemPrompt, userContent, ai, temperature
232
394
 
233
395
  if (!res.ok) {
234
396
  const body = await res.text();
235
- throw new Error(`OpenRouter API ${res.status}: ${body}`);
397
+ const err = new Error(`OpenRouter API ${res.status}: ${body}`);
398
+ err.status = res.status;
399
+ throw err;
236
400
  }
237
401
 
238
- const data = await res.json();
239
- const content = stripReasoning(data?.choices?.[0]?.message?.content || '');
402
+ // Um 200 com corpo vazio ou cortado acontece em gerações longas. `res.json()`
403
+ // cru lançaria "Unexpected end of JSON input" — mensagem que não diz nada a
404
+ // quem está olhando o board. Lê como texto, reporta o que veio e marca como
405
+ // transitória para o retry pegar.
406
+ const raw = await res.text();
407
+ let data;
408
+ try {
409
+ data = JSON.parse(raw);
410
+ } catch {
411
+ const err = new Error(
412
+ `OpenRouter devolveu ${res.status} com corpo inválido (${raw.length} bytes): ` +
413
+ `${raw.slice(0, 200) || '(vazio)'}`
414
+ );
415
+ err.transient = true;
416
+ throw err;
417
+ }
418
+
419
+ const choice = data?.choices?.[0];
420
+ const content = stripReasoning(choice?.message?.content || '');
421
+
422
+ // finish_reason='length' = cortado no teto. Checado ANTES do conteúdo vazio:
423
+ // um corte durante o raciocínio devolve texto vazio, e "resposta vazia" (que
424
+ // é tratada como transitória) esconderia a causa real por trás de 3 retries.
425
+ if (isTruncationReason(choice?.finish_reason)) {
426
+ throw new TruncatedOutputError({
427
+ provider: 'openrouter',
428
+ model: ai.model,
429
+ maxTokens,
430
+ // native_finish_reason preserva o motivo cru do provedor upstream.
431
+ reason: `${choice.finish_reason}${choice.native_finish_reason ? `/${choice.native_finish_reason}` : ''}`,
432
+ chars: content.length,
433
+ });
434
+ }
240
435
  if (!content) {
241
- throw new Error(`OpenRouter retornou resposta vazia: ${JSON.stringify(data)}`);
436
+ const err = new Error(`OpenRouter retornou resposta vazia: ${JSON.stringify(data)}`);
437
+ err.transient = true;
438
+ throw err;
242
439
  }
243
440
  return { text: content, usage: extractOpenRouterUsage(data.usage) };
244
441
  }
@@ -151,7 +151,11 @@ export async function runCritique({ kind, spec, plan, techContextYaml, stories,
151
151
  const raw = await generateDocument(buildSystemPrompt(kind), userContent, {
152
152
  action: 'critique',
153
153
  temperature: 0,
154
- maxTokens: 4096,
154
+ // Teto próprio: a crítica devolve uma lista JSON de findings, bem menor que
155
+ // um spec/plan. 4096 ficava justo quando os dois documentos são longos e o
156
+ // JSON vinha cortado — o parse falhava sem dizer por quê. Com a detecção de
157
+ // truncamento o corte agora é explícito, e a folga evita chegar nele.
158
+ maxTokens: 8192,
155
159
  usage,
156
160
  });
157
161
 
@@ -0,0 +1,54 @@
1
+ // Rede de segurança contra documento cortado.
2
+ //
3
+ // A defesa principal contra truncamento é a detecção no provedor
4
+ // (claude.mjs → TruncatedOutputError): um documento cortado nunca chega a ser
5
+ // gravado. Esta checagem cobre o que escapa disso — documento editado à mão,
6
+ // commit parcial, ou geração feita por uma versão antiga da CLI.
7
+ //
8
+ // O critério é DELIBERADAMENTE estreito. Só entram sinais que não têm leitura
9
+ // inocente em Markdown: um falso positivo aqui bloqueia o ready de uma Feature
10
+ // legítima, o que é pior que deixar passar um documento suspeito. "Termina sem
11
+ // ponto final" e afins ficaram de fora de propósito — títulos, tabelas e itens
12
+ // de lista terminam assim o tempo todo.
13
+
14
+ /**
15
+ * Procura sinais objetivos de documento incompleto (função PURA).
16
+ *
17
+ * @param {string} content conteúdo do markdown
18
+ * @returns {string[]} descrições dos problemas encontrados (vazio = sem sinais)
19
+ */
20
+ export function findIncompleteDocSigns(content) {
21
+ const problems = [];
22
+ const text = content || '';
23
+
24
+ if (!text.trim()) {
25
+ return ['o arquivo está vazio'];
26
+ }
27
+
28
+ // 1. Cerca de código aberta. Um ``` sem par significa que o corte aconteceu
29
+ // dentro de um bloco (gherkin, mermaid, yaml) — não há leitura válida.
30
+ const fences = text.split('\n').filter((line) => /^\s*```/.test(line)).length;
31
+ if (fences % 2 !== 0) {
32
+ problems.push(`bloco de código não fechado (${fences} marcações \`\`\`)`);
33
+ }
34
+
35
+ // 2. Parênteses/colchetes abertos na última linha com conteúdo. É o formato
36
+ // exato de um corte no meio da frase — ex.: "...(versionCode/S" — e prosa
37
+ // completa não termina assim.
38
+ const lines = text.split('\n').filter((line) => line.trim());
39
+ const lastLine = lines[lines.length - 1] || '';
40
+ for (const [open, close, nome] of [['(', ')', 'parêntese'], ['[', ']', 'colchete']]) {
41
+ const opened = lastLine.split(open).length - 1;
42
+ const closed = lastLine.split(close).length - 1;
43
+ if (opened > closed) {
44
+ problems.push(`${nome} aberto e não fechado na última linha: "${truncate(lastLine)}"`);
45
+ }
46
+ }
47
+
48
+ return problems;
49
+ }
50
+
51
+ function truncate(line, max = 60) {
52
+ const t = line.trim();
53
+ return t.length > max ? `${t.slice(0, max)}…` : t;
54
+ }
@@ -0,0 +1,34 @@
1
+ // Detecção do modo "force" (re-executar a etapa ignorando os guards).
2
+ //
3
+ // Os comandos generate-spec/generate-plan/decompose rodam dentro de Actions
4
+ // disparadas por label — a linha de comando do workflow é fixa e não tem como
5
+ // receber uma flag. Por isso o force chega de duas formas equivalentes:
6
+ // • label `spec-wave:force` na issue (fluxo normal, pelo board);
7
+ // • flag `--force` (execução local/manual da CLI).
8
+ // A label é CONSUMIDA pelo run que a leu (ver consumeForceLabel), senão ela
9
+ // ficaria pendurada e forçaria silenciosamente todos os runs seguintes.
10
+ import { LABEL_FORCE } from '../config.mjs';
11
+ import { removeLabel } from '../api/github-rest.mjs';
12
+
13
+ /**
14
+ * Diz se a etapa deve ser re-executada ignorando os guards (função PURA).
15
+ *
16
+ * @param {object} [params]
17
+ * @param {Array<string|{name: string}>} [params.labels] labels da issue
18
+ * @param {boolean} [params.flag] valor da flag `--force` da CLI
19
+ * @returns {boolean}
20
+ */
21
+ export function isForced({ labels = [], flag = false } = {}) {
22
+ if (flag) return true;
23
+ return (labels || [])
24
+ .map(l => (typeof l === 'string' ? l : l?.name))
25
+ .includes(LABEL_FORCE);
26
+ }
27
+
28
+ /**
29
+ * Remove a label `spec-wave:force` da issue. Best-effort: nunca lança — deixar
30
+ * de consumir a label é um incômodo, não um motivo para derrubar o comando.
31
+ */
32
+ export async function consumeForceLabel(token, owner, repo, issueNumber) {
33
+ await removeLabel(token, owner, repo, issueNumber, LABEL_FORCE).catch(() => {});
34
+ }
@@ -0,0 +1,17 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
3
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
4
+ <plist version="1.0"><dict>
5
+ <key>Label</key><string>dev.specwave.agent</string>
6
+ <key>ProgramArguments</key>
7
+ <array><string>{bin}</string></array>
8
+ <key>RunAtLoad</key><true/>
9
+ <key>KeepAlive</key><true/>
10
+ <key>EnvironmentVariables</key>
11
+ <dict>
12
+ <key>RUST_LOG</key><string>info</string>
13
+ <key>PATH</key><string>{path}</string>
14
+ </dict>
15
+ <key>StandardOutPath</key><string>{home}/Library/Logs/spec-wave-agent.log</string>
16
+ <key>StandardErrorPath</key><string>{home}/Library/Logs/spec-wave-agent.log</string>
17
+ </dict></plist>
@@ -0,0 +1,18 @@
1
+ [Unit]
2
+ Description=spec-wave dev agent
3
+ After=network-online.target
4
+ Wants=network-online.target
5
+
6
+ [Service]
7
+ ExecStart={bin}
8
+ Restart=on-failure
9
+ RestartSec=10
10
+ Environment=RUST_LOG=info
11
+ Environment=PATH={path}
12
+ # SIGTERM no desligamento => checkpoint + release do lease (takeover imediato)
13
+ KillSignal=SIGTERM
14
+ # Deve ser maior que o tempo do checkpoint (commit + push do WIP)
15
+ TimeoutStopSec=30
16
+
17
+ [Install]
18
+ WantedBy=default.target