@spec-wave/cli 0.9.0 → 0.11.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.
package/README.md CHANGED
@@ -26,7 +26,6 @@ O resultado é um board Kanban no GitHub Projects v2 que avança automaticamente
26
26
  → 📋 Spec ← label spec-wave:spec → Action gera spec.md
27
27
  → 📋 Plan ← label spec-wave:plan → Action gera plan.md
28
28
  → ✅ Ready ← label spec-wave:ready → Action valida ambos
29
- → 📋 Backlog Técnico
30
29
  → 🚧 Desenvolvimento ← comando local: spec-wave implement <n>
31
30
  → 👀 Code Review ← PR aberto → Action move automaticamente
32
31
  → 🧪 QA ← PR aprovado → Action move automaticamente
@@ -304,7 +303,7 @@ O Action `decompose.yml` usa IA para criar sub-issues da Feature #12:
304
303
  #19 [TASK] Notificação por e-mail ao confirmar
305
304
  ```
306
305
 
307
- Todas as Stories e Tasks são adicionadas ao board em **📋 Backlog Técnico** com Status `Todo`.
306
+ Todas as Stories e Tasks são adicionadas ao board em **✅ Ready** com Status `Todo`.
308
307
 
309
308
  ---
310
309
 
package/bin/spec-wave.mjs CHANGED
@@ -222,6 +222,23 @@ program
222
222
  await story({ action, issue: n }).catch(err => { console.error(err.message); process.exit(1); });
223
223
  });
224
224
 
225
+ program
226
+ .command('dev-agent')
227
+ .description('Instala (--install/--build) ou executa (--run) o spec-wave-agent nesta máquina')
228
+ .option('--install', 'Baixa o binário da release, gera a config e (com --service) o serviço')
229
+ .option('--build', 'Clona o repo do agente e compila com cargo (alternativa ao --install)')
230
+ .option('--run', 'Executa o agente em foreground (Ctrl+C encerra com checkpoint)')
231
+ .option('--service', 'No --install/--build: também instala e habilita systemd/launchd')
232
+ .option('--tag <tag>', 'Release (--install) ou branch/tag (--build); padrão: última release / main')
233
+ .option('--debug', 'No --run: RUST_LOG=debug')
234
+ .option('--dry-run', 'Mostra o que seria instalado sem gravar')
235
+ .option('--force', 'Reinstala o binário e regrava a config')
236
+ .option('--yes', 'Modo não-interativo')
237
+ .action(async (options) => {
238
+ const { devAgent } = await import('../src/commands/dev-agent.mjs');
239
+ await devAgent(options).catch(err => { console.error(err.message); process.exit(1); });
240
+ });
241
+
225
242
  program
226
243
  .command('doctor')
227
244
  .description('Diagnostica a configuração do spec-wave no repositório atual')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spec-wave/cli",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Setup spec-driven GitHub workflow with Projects v2, labels, issue templates, and AI-powered Actions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,4 +28,4 @@
28
28
  "commander": "^13.1.0",
29
29
  "js-yaml": "^4.1.0"
30
30
  }
31
- }
31
+ }
@@ -265,7 +265,7 @@ async function decomposeFeature(ctx) {
265
265
  token, owner, repo, parseInt(issueNumber, 10),
266
266
  `🔀 **Decomposição concluída!**\n\n` +
267
267
  `Foram criados ${decomposition.stories.length} stories e suas tasks:\n\n${list}\n\n` +
268
- `Mova o card para **📋 Backlog Técnico** para iniciar o desenvolvimento.` +
268
+ `Tudo posicionado em **✅ Ready**. Inicie o desenvolvimento com \`npx @spec-wave/cli@latest implement ${issueNumber}\` (Stories em ordem de dependência).` +
269
269
  formatItemsLintWarning(generatedTexts)
270
270
  );
271
271
  console.log(`Decomposição concluída: ${decomposition.stories.length} stories criadas.`);
@@ -321,7 +321,7 @@ async function decomposeRFC(ctx) {
321
321
  token, owner, repo, parseInt(issueNumber, 10),
322
322
  `🔀 **Decomposição do RFC concluída!**\n\n` +
323
323
  `Foram criadas ${created.length} tasks:\n\n${list}\n\n` +
324
- `Mova o card para **📋 Backlog Técnico** para iniciar o desenvolvimento.` +
324
+ `Tudo posicionado em **✅ Ready**. Inicie o desenvolvimento com \`npx @spec-wave/cli@latest implement <task>\`.` +
325
325
  formatItemsLintWarning(generatedTexts)
326
326
  );
327
327
  console.log(`Decomposição concluída: ${created.length} tasks criadas.`);
@@ -0,0 +1,568 @@
1
+ // spec-wave dev-agent --install | --run
2
+ //
3
+ // Instala e executa o spec-wave-agent (daemon Rust) na máquina do dev. O
4
+ // binário vem das releases do repo do agente (baixado com `gh release
5
+ // download`, que usa as credenciais do próprio dev), a config é derivada do
6
+ // .spec-wave.json do repositório e o serviço (systemd/launchd) é opcional.
7
+ //
8
+ // Convenção do repo: a lógica de decisão fica em funções puras exportadas
9
+ // (testadas em test/dev-agent.test.mjs) e o I/O fica na função imperativa.
10
+
11
+ import * as p from '@clack/prompts';
12
+ import chalk from 'chalk';
13
+ import { createHash } from 'node:crypto';
14
+ import { execSync } from 'node:child_process';
15
+ import {
16
+ chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync,
17
+ readFileSync, rmSync, writeFileSync,
18
+ } from 'node:fs';
19
+ import { homedir, tmpdir } from 'node:os';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { CONFIG_FILE } from '../config.mjs';
23
+
24
+ const __dir = path.dirname(fileURLToPath(import.meta.url));
25
+ const TEMPLATES_DIR = path.join(__dir, '..', 'templates', 'agent');
26
+
27
+ /** Repositório que publica as releases do agente. */
28
+ export const AGENT_REPO = 'astratech-net-br/spec-wave-agent';
29
+ export const BIN_NAME = 'spec-wave-agent';
30
+ /** Instalação sem sudo. */
31
+ export const BIN_DIR = path.join('.local', 'bin');
32
+ export const AGENT_CONFIG = path.join('.config', 'spec-wave-agent', 'config.toml');
33
+ /** Tag instalada — arquivo de estado escrito por nós.
34
+ * NÃO detectamos versão executando o binário: versões antigas não têm
35
+ * --version e subiriam o DAEMON no lugar de imprimir a versão. */
36
+ export const STATE_FILE = path.join('.local', 'share', 'spec-wave-agent', 'installed-tag');
37
+ /** Clone usado pelo --build (reaproveitado entre builds). */
38
+ export const SRC_DIR = path.join('.local', 'share', 'spec-wave-agent', 'src');
39
+
40
+ /** Marca de versão para binários compilados do fonte. */
41
+ export function sourceTag(ref, sha) {
42
+ const short = (sha || '').slice(0, 7);
43
+ return `fonte:${ref}${short ? `@${short}` : ''}`;
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Puro (testável)
48
+ // ---------------------------------------------------------------------------
49
+
50
+ const TARGETS = {
51
+ 'linux:x64': 'x86_64-unknown-linux-gnu',
52
+ 'darwin:arm64': 'aarch64-apple-darwin',
53
+ 'win32:x64': 'x86_64-pc-windows-msvc',
54
+ };
55
+
56
+ /** Sufixo do executável na plataforma. */
57
+ export function exeSuffix(platform) {
58
+ return platform === 'win32' ? '.exe' : '';
59
+ }
60
+
61
+ /**
62
+ * Nome do asset da release para a plataforma. Recebe platform/arch por
63
+ * parâmetro (em vez de ler process.*) para ser testável.
64
+ * @returns {string|null} null quando a plataforma não tem binário publicado
65
+ */
66
+ export function assetNameFor(platform, arch) {
67
+ const target = TARGETS[`${platform}:${arch}`];
68
+ return target ? `${BIN_NAME}-${target}${exeSuffix(platform)}` : null;
69
+ }
70
+
71
+ /** Substitui {placeholders} — mesmo idiom do renderCommand do implement. */
72
+ function render(template, vars) {
73
+ return template.replace(/\{(\w+)\}/g, (m, key) => (key in vars ? vars[key] : m));
74
+ }
75
+
76
+ /**
77
+ * config.toml do agente a partir do .spec-wave.json. Nunca escreve token.
78
+ */
79
+ export function renderAgentConfig({ owner, repo }) {
80
+ return `# spec-wave-agent — gerado por \`spec-wave dev-agent --install\`
81
+ # Schema completo: https://github.com/${AGENT_REPO}#configuração
82
+
83
+ repo = "${owner}/${repo}"
84
+ queue_label = "spec-wave:dev-agent"
85
+
86
+ # Defaults do agente (descomente para ajustar):
87
+ #poll_interval_secs = 60 # consulta à fila quando ocioso
88
+ #heartbeat_secs = 120 # renovação do lease
89
+ #lease_ttl_secs = 600 # >= 4x heartbeat (validado no boot)
90
+ #implement_timeout_secs = 14400 # teto de UMA feature inteira (4h)
91
+ #cooldown_secs = 900 # issue processada sai da fila local por este tempo
92
+ #max_executor_rounds = 8 # teto de rodadas do executor por feature
93
+
94
+ # Executor (Claude Code). Para fixar o modelo, acrescente --model:
95
+ #feature_command = "claude -p --model opus --output-format stream-json --verbose --permission-mode acceptEdits --allowedTools \\"Bash(npx:*),Bash(git:*),Edit,Write,Read,Glob,Grep,Task\\""
96
+
97
+ # Origin alternativo (SSH, git self-hosted). Default: https://github.com/{repo}.git
98
+ #remote_url = "git@github.com:${owner}/${repo}.git"
99
+ `;
100
+ }
101
+
102
+ /**
103
+ * Unit do serviço a partir dos templates.
104
+ * @param {'systemd'|'launchd'} kind
105
+ */
106
+ export function renderServiceUnit(kind, { home, bin, pathEnv }) {
107
+ const file = kind === 'launchd' ? 'dev.specwave.agent.plist' : 'spec-wave-agent.service';
108
+ const raw = readFileSync(path.join(TEMPLATES_DIR, file), 'utf-8');
109
+ return render(raw, { home, bin, path: pathEnv });
110
+ }
111
+
112
+ /** O diretório já está no PATH? */
113
+ export function isOnPath(dir, pathEnv) {
114
+ if (!pathEnv) return false;
115
+ return pathEnv.split(path.delimiter).filter(Boolean).some((e) => path.resolve(e) === path.resolve(dir));
116
+ }
117
+
118
+ /**
119
+ * O que o --install vai fazer, para o relatório e o --dry-run.
120
+ * @param {{binPath, binInstalled, installedTag, targetTag, configPath,
121
+ * configExists, service, servicePath, force}} ctx
122
+ * @returns {Array<{what: string, path: string, action: 'criar'|'atualizar'|'manter'}>}
123
+ */
124
+ export function planInstall(ctx) {
125
+ const items = [];
126
+ const sameVersion = ctx.binInstalled
127
+ && !!ctx.installedTag
128
+ && ctx.installedTag === ctx.targetTag;
129
+ items.push({
130
+ what: `binário ${ctx.targetTag ?? ''}`.trim(),
131
+ path: ctx.binPath,
132
+ action: !ctx.binInstalled ? 'criar' : (sameVersion && !ctx.force ? 'manter' : 'atualizar'),
133
+ });
134
+ items.push({
135
+ what: 'config',
136
+ path: ctx.configPath,
137
+ action: !ctx.configExists ? 'criar' : (ctx.force ? 'atualizar' : 'manter'),
138
+ });
139
+ if (ctx.service) {
140
+ items.push({
141
+ what: `serviço (${ctx.service})`,
142
+ path: ctx.servicePath,
143
+ action: ctx.force ? 'atualizar' : 'criar',
144
+ });
145
+ }
146
+ return items;
147
+ }
148
+
149
+ const ACTION_MARK = { criar: chalk.green('+'), atualizar: chalk.yellow('~'), manter: chalk.dim('=') };
150
+
151
+ export function renderInstallReport(items) {
152
+ return items
153
+ .map((i) => `${ACTION_MARK[i.action] ?? '?'} ${i.what}: ${chalk.dim(i.path)} ${chalk.dim(`(${i.action})`)}`)
154
+ .join('\n');
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // I/O
159
+ // ---------------------------------------------------------------------------
160
+
161
+ function sh(cmd) {
162
+ return execSync(cmd, { stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim();
163
+ }
164
+
165
+ function has(cmd) {
166
+ const probe = process.platform === 'win32' ? `where ${cmd}` : `command -v ${cmd}`;
167
+ try { sh(probe); return true; } catch { return false; }
168
+ }
169
+
170
+ /** Tag instalada (arquivo de estado), ou null se desconhecida. */
171
+ function readInstalledTag(home) {
172
+ try { return readFileSync(path.join(home, STATE_FILE), 'utf-8').trim() || null; }
173
+ catch { return null; }
174
+ }
175
+
176
+ function writeInstalledTag(home, tag) {
177
+ const f = path.join(home, STATE_FILE);
178
+ mkdirSync(path.dirname(f), { recursive: true });
179
+ writeFileSync(f, `${tag}\n`, 'utf-8');
180
+ }
181
+
182
+ function readRepoConfig(cwd) {
183
+ const configPath = path.join(cwd, CONFIG_FILE);
184
+ if (!existsSync(configPath)) {
185
+ return { error: `${CONFIG_FILE} não encontrado. Rode \`spec-wave init\` neste repositório primeiro.` };
186
+ }
187
+ try {
188
+ const cfg = JSON.parse(readFileSync(configPath, 'utf-8'));
189
+ if (!cfg.owner || !cfg.repo) {
190
+ return { error: `${CONFIG_FILE} não contém owner/repo. Rode \`spec-wave init\` novamente.` };
191
+ }
192
+ return { cfg };
193
+ } catch (err) {
194
+ return { error: `${CONFIG_FILE} corrompido: ${err.message}` };
195
+ }
196
+ }
197
+
198
+ /** Checagens de pré-requisito no estilo do doctor: nunca lança. */
199
+ function preflight(cfg) {
200
+ const problems = [];
201
+ const warnings = [];
202
+ if (!has('git')) problems.push('git não encontrado no PATH.');
203
+ if (!has('gh')) {
204
+ problems.push('gh (GitHub CLI) não encontrado — instale: https://cli.github.com');
205
+ } else {
206
+ try { sh('gh auth status'); }
207
+ catch { problems.push('gh não autenticado — rode `gh auth login`.'); }
208
+ }
209
+ if (!has('npx')) problems.push('npx não encontrado — instale Node 18+.');
210
+ if (!has('claude')) {
211
+ warnings.push('Claude Code (claude) não encontrado no PATH — o agente precisa dele para implementar.');
212
+ }
213
+ if (!cfg?.specKit?.command && !process.env.SPEC_WAVE_IMPLEMENT_CMD) {
214
+ warnings.push(
215
+ `${CONFIG_FILE} sem specKit.command — cada story precisa dele para ser implementada. ` +
216
+ 'Configure "specKit": { "command": "claude -p \\"...{tasksFile}...\\" --permission-mode acceptEdits" } ' +
217
+ 'ou exporte SPEC_WAVE_IMPLEMENT_CMD.'
218
+ );
219
+ }
220
+ return { problems, warnings };
221
+ }
222
+
223
+ function latestTag() {
224
+ try {
225
+ return sh(`gh release view --repo ${AGENT_REPO} --json tagName --jq .tagName`);
226
+ } catch (err) {
227
+ const msg = String(err.stderr || err.message || '');
228
+ if (/HTTP 404|Not Found|release not found/i.test(msg)) {
229
+ throw new Error(
230
+ `Nenhuma release publicada em ${AGENT_REPO} (ou você não tem acesso ao repositório privado). ` +
231
+ 'Peça acesso à organização ou instale a partir do fonte (veja o README do agente).'
232
+ );
233
+ }
234
+ throw new Error(`Não foi possível consultar as releases de ${AGENT_REPO}: ${msg.trim() || err.message}`);
235
+ }
236
+ }
237
+
238
+ /** Baixa o asset + .sha256 num tempdir, confere o hash e devolve o caminho. */
239
+ function downloadAsset(tag, asset) {
240
+ const dir = mkdtempSync(path.join(tmpdir(), 'spec-wave-agent-'));
241
+ try {
242
+ sh(`gh release download ${tag} --repo ${AGENT_REPO} --pattern "${asset}" --pattern "${asset}.sha256" --dir "${dir}" --clobber`);
243
+ } catch (err) {
244
+ rmSync(dir, { recursive: true, force: true });
245
+ const msg = String(err.stderr || err.message || '');
246
+ if (/no assets match|asset.*not found/i.test(msg)) {
247
+ throw new Error(`A release ${tag} não tem o binário para esta plataforma (${asset}).`);
248
+ }
249
+ throw new Error(`Falha ao baixar ${asset} da release ${tag}: ${msg.trim() || err.message}`);
250
+ }
251
+ const binTmp = path.join(dir, asset);
252
+ const sumFile = path.join(dir, `${asset}.sha256`);
253
+ if (existsSync(sumFile)) {
254
+ const expected = readFileSync(sumFile, 'utf-8').trim().split(/\s+/)[0];
255
+ const actual = createHash('sha256').update(readFileSync(binTmp)).digest('hex');
256
+ if (expected !== actual) {
257
+ rmSync(dir, { recursive: true, force: true });
258
+ throw new Error(`Checksum do binário não confere (esperado ${expected}, obtido ${actual}).`);
259
+ }
260
+ }
261
+ return { dir, binTmp };
262
+ }
263
+
264
+ function installService({ kind, home, binPath, servicePath }) {
265
+ const pathEnv = [path.dirname(binPath), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin']
266
+ .filter((d, i, a) => a.indexOf(d) === i)
267
+ .join(':');
268
+ const content = renderServiceUnit(kind, { home, bin: binPath, pathEnv });
269
+ mkdirSync(path.dirname(servicePath), { recursive: true });
270
+ writeFileSync(servicePath, content, 'utf-8');
271
+ if (kind === 'systemd') {
272
+ execSync('systemctl --user daemon-reload', { stdio: 'inherit' });
273
+ execSync('systemctl --user enable --now spec-wave-agent', { stdio: 'inherit' });
274
+ } else {
275
+ mkdirSync(path.join(home, 'Library', 'Logs'), { recursive: true });
276
+ try { execSync(`launchctl unload "${servicePath}"`, { stdio: ['pipe', 'pipe', 'pipe'] }); } catch { /* não estava carregado */ }
277
+ execSync(`launchctl load "${servicePath}"`, { stdio: 'inherit' });
278
+ }
279
+ }
280
+
281
+ async function install(options) {
282
+ const cwd = process.cwd();
283
+ const home = homedir();
284
+ const binPath = path.join(home, BIN_DIR, BIN_NAME + exeSuffix(process.platform));
285
+ const configPath = path.join(home, AGENT_CONFIG);
286
+
287
+ // 1. Repositório + pré-requisitos.
288
+ const { cfg, error } = readRepoConfig(cwd);
289
+ if (error) { p.log.error(error); process.exitCode = 1; return; }
290
+
291
+ const { problems, warnings } = preflight(cfg);
292
+ for (const w of warnings) p.log.warn(w);
293
+ if (problems.length > 0) {
294
+ p.log.error(`Pré-requisitos faltando:\n - ${problems.join('\n - ')}`);
295
+ process.exitCode = 1;
296
+ return;
297
+ }
298
+
299
+ // 2. Plataforma.
300
+ const asset = assetNameFor(process.platform, process.arch);
301
+ if (!asset) {
302
+ p.log.error(
303
+ `Sem binário publicado para ${process.platform}/${process.arch}. ` +
304
+ 'Compile a partir do fonte (veja o README do agente).'
305
+ );
306
+ process.exitCode = 1;
307
+ return;
308
+ }
309
+
310
+ // 3. Versão alvo x instalada.
311
+ const spin = p.spinner();
312
+ spin.start('Consultando a release do agente...');
313
+ let targetTag;
314
+ try { targetTag = options.tag || latestTag(); }
315
+ catch (err) { spin.stop('Falhou.'); p.log.error(err.message); process.exitCode = 1; return; }
316
+ spin.stop(`Release alvo: ${chalk.bold(targetTag)}`);
317
+
318
+ const binInstalled = existsSync(binPath);
319
+ const installedTag = binInstalled ? readInstalledTag(home) : null;
320
+ const items = planInstall({
321
+ binPath, binInstalled, installedTag, targetTag, configPath,
322
+ configExists: existsSync(configPath),
323
+ service: options.service ? (process.platform === 'darwin' ? 'launchd' : 'systemd') : null,
324
+ servicePath: process.platform === 'darwin'
325
+ ? path.join(home, 'Library', 'LaunchAgents', 'dev.specwave.agent.plist')
326
+ : path.join(home, '.config', 'systemd', 'user', 'spec-wave-agent.service'),
327
+ force: !!options.force,
328
+ });
329
+ p.note(renderInstallReport(items), 'Plano de instalação');
330
+
331
+ if (options.dryRun) {
332
+ p.outro('Dry-run: nada foi instalado.');
333
+ return;
334
+ }
335
+
336
+ // 4. Binário.
337
+ const binItem = items[0];
338
+ if (binItem.action === 'manter') {
339
+ p.log.info(`Binário já está em ${targetTag} — nada a baixar (use --force para reinstalar).`);
340
+ } else {
341
+ const s = p.spinner();
342
+ s.start(`Baixando ${asset} (${targetTag})...`);
343
+ let tmp;
344
+ try { tmp = downloadAsset(targetTag, asset); }
345
+ catch (err) { s.stop('Falhou.'); p.log.error(err.message); process.exitCode = 1; return; }
346
+ try {
347
+ mkdirSync(path.dirname(binPath), { recursive: true });
348
+ copyFileSync(tmp.binTmp, binPath);
349
+ chmodSync(binPath, 0o755);
350
+ writeInstalledTag(home, targetTag);
351
+ } finally {
352
+ rmSync(tmp.dir, { recursive: true, force: true });
353
+ }
354
+ s.stop(`Binário instalado em ${chalk.cyan(binPath)}.`);
355
+ }
356
+
357
+ // 5-7. Config, serviço e avisos (compartilhado com o --build).
358
+ await finishSetup({ cfg, home, binPath, configPath, options, configAction: items[1].action,
359
+ servicePath: options.service ? items[2].path : null });
360
+ }
361
+
362
+ /** Config + serviço + avisos finais — comum a --install e --build. */
363
+ async function finishSetup({ cfg, home, binPath, configPath, options, configAction, servicePath }) {
364
+ if (configAction === 'manter') {
365
+ p.log.info(`Config mantida: ${configPath} já existe (use --force para regerar).`);
366
+ } else if (existsSync(configPath) && !options.yes) {
367
+ const ok = await p.confirm({ message: `Sobrescrever ${configPath}?`, initialValue: false });
368
+ if (p.isCancel(ok) || !ok) {
369
+ p.log.info('Config mantida.');
370
+ } else {
371
+ mkdirSync(path.dirname(configPath), { recursive: true });
372
+ writeFileSync(configPath, renderAgentConfig(cfg), 'utf-8');
373
+ p.log.success(`Config regravada em ${configPath}.`);
374
+ }
375
+ } else {
376
+ mkdirSync(path.dirname(configPath), { recursive: true });
377
+ writeFileSync(configPath, renderAgentConfig(cfg), 'utf-8');
378
+ p.log.success(`Config criada em ${chalk.cyan(configPath)} (repo ${cfg.owner}/${cfg.repo}).`);
379
+ }
380
+
381
+ if (options.service && process.platform === 'win32') {
382
+ p.log.warn('--service não é suportado no Windows (sem systemd/launchd). Use `spec-wave dev-agent --run`.');
383
+ } else if (options.service && servicePath) {
384
+ const kind = process.platform === 'darwin' ? 'launchd' : 'systemd';
385
+ try {
386
+ installService({ kind, home, binPath, servicePath });
387
+ p.log.success(
388
+ kind === 'systemd'
389
+ ? 'Serviço systemd habilitado. Logs: journalctl --user -u spec-wave-agent -f'
390
+ : `Serviço launchd carregado. Logs: tail -f ${home}/Library/Logs/spec-wave-agent.log`
391
+ );
392
+ } catch (err) {
393
+ p.log.warn(`Falha ao configurar o serviço: ${err.message}`);
394
+ }
395
+ }
396
+
397
+ const binDir = path.dirname(binPath);
398
+ if (!isOnPath(binDir, process.env.PATH)) {
399
+ p.log.warn(`${binDir} não está no PATH. Adicione ao seu shell:\n export PATH="${binDir}:$PATH"`);
400
+ }
401
+ let ghLogin = null;
402
+ try { ghLogin = sh('gh api user --jq .login'); } catch { /* best-effort */ }
403
+ if (ghLogin && cfg.owner && ghLogin.toLowerCase() !== cfg.owner.toLowerCase()) {
404
+ p.log.info(
405
+ `A conta ativa do gh é ${ghLogin} e o repo é de ${cfg.owner}. Se o agente não enxergar as issues, ` +
406
+ 'exporte o token da conta certa:\n export GH_TOKEN=$(gh auth token -u <sua-conta>)' +
407
+ (options.service ? '\n (no serviço: systemctl --user edit spec-wave-agent → Environment=GH_TOKEN=...)' : '')
408
+ );
409
+ }
410
+
411
+ p.outro(
412
+ `${chalk.green('✓')} Agente pronto.\n` +
413
+ ` Rodar agora: ${chalk.cyan('spec-wave dev-agent --run')}\n` +
414
+ ' Enfileirar: aplique a label spec-wave:dev-agent numa issue [FEATURE] já decomposta.'
415
+ );
416
+ }
417
+
418
+ /**
419
+ * --build: clona (ou atualiza) o repo do agente e compila com cargo.
420
+ * Alternativa ao --install para plataformas sem binário publicado (Mac Intel,
421
+ * Linux arm64) ou para rodar o código mais recente da main.
422
+ */
423
+ async function build(options) {
424
+ const cwd = process.cwd();
425
+ const home = homedir();
426
+ const binPath = path.join(home, BIN_DIR, BIN_NAME + exeSuffix(process.platform));
427
+ const configPath = path.join(home, AGENT_CONFIG);
428
+ const srcDir = path.join(home, SRC_DIR);
429
+
430
+ const { cfg, error } = readRepoConfig(cwd);
431
+ if (error) { p.log.error(error); process.exitCode = 1; return; }
432
+
433
+ const { problems, warnings } = preflight(cfg);
434
+ if (!has('cargo')) {
435
+ problems.push('cargo (Rust) não encontrado — instale com: curl https://sh.rustup.rs -sSf | sh');
436
+ }
437
+ for (const w of warnings) p.log.warn(w);
438
+ if (problems.length > 0) {
439
+ p.log.error(`Pré-requisitos faltando:\n - ${problems.join('\n - ')}`);
440
+ process.exitCode = 1;
441
+ return;
442
+ }
443
+
444
+ const ref = options.tag || 'main';
445
+ const items = planInstall({
446
+ binPath, binInstalled: existsSync(binPath), installedTag: null,
447
+ targetTag: `fonte:${ref}`, configPath, configExists: existsSync(configPath),
448
+ service: options.service ? (process.platform === 'darwin' ? 'launchd' : 'systemd') : null,
449
+ servicePath: process.platform === 'darwin'
450
+ ? path.join(home, 'Library', 'LaunchAgents', 'dev.specwave.agent.plist')
451
+ : path.join(home, '.config', 'systemd', 'user', 'spec-wave-agent.service'),
452
+ force: !!options.force,
453
+ });
454
+ p.note(`${renderInstallReport(items)}\n${chalk.dim(`fonte: ${srcDir} (${ref})`)}`, 'Plano de build');
455
+
456
+ if (options.dryRun) { p.outro('Dry-run: nada foi compilado.'); return; }
457
+
458
+ // 1. Clone ou atualização do fonte.
459
+ const s = p.spinner();
460
+ try {
461
+ if (!existsSync(path.join(srcDir, '.git'))) {
462
+ s.start(`Clonando ${AGENT_REPO}...`);
463
+ mkdirSync(path.dirname(srcDir), { recursive: true });
464
+ sh(`gh repo clone ${AGENT_REPO} "${srcDir}"`);
465
+ } else {
466
+ s.start('Atualizando o fonte...');
467
+ sh(`git -C "${srcDir}" fetch --all --tags --prune`);
468
+ }
469
+ sh(`git -C "${srcDir}" checkout --quiet ${ref}`);
470
+ // Branch (não tag): traz os commits novos.
471
+ try { sh(`git -C "${srcDir}" merge --ff-only "origin/${ref}"`); } catch { /* tag/detached: ok */ }
472
+ s.stop(`Fonte pronto em ${chalk.cyan(srcDir)} (${ref}).`);
473
+ } catch (err) {
474
+ s.stop('Falhou.');
475
+ const msg = String(err.stderr || err.message || '');
476
+ p.log.error(
477
+ /not found|Could not resolve|permission/i.test(msg)
478
+ ? `Não foi possível clonar ${AGENT_REPO} — verifique se a conta ativa do gh tem acesso ao repositório privado.`
479
+ : `Falha ao preparar o fonte: ${msg.trim() || err.message}`
480
+ );
481
+ process.exitCode = 1;
482
+ return;
483
+ }
484
+
485
+ // 2. Build (demora: mostra a saída do cargo ao vivo).
486
+ p.log.step('Compilando com cargo (pode levar alguns minutos)...');
487
+ try {
488
+ execSync('cargo build --release', { cwd: srcDir, stdio: 'inherit' });
489
+ } catch (err) {
490
+ p.log.error(`cargo build falhou: ${err.message}`);
491
+ process.exitCode = 1;
492
+ return;
493
+ }
494
+
495
+ // 3. Instala o binário compilado.
496
+ const built = path.join(srcDir, 'target', 'release', BIN_NAME + exeSuffix(process.platform));
497
+ if (!existsSync(built)) {
498
+ p.log.error(`Binário não encontrado após o build: ${built}`);
499
+ process.exitCode = 1;
500
+ return;
501
+ }
502
+ mkdirSync(path.dirname(binPath), { recursive: true });
503
+ copyFileSync(built, binPath);
504
+ chmodSync(binPath, 0o755);
505
+ let sha = '';
506
+ try { sha = sh(`git -C "${srcDir}" rev-parse HEAD`); } catch { /* best-effort */ }
507
+ writeInstalledTag(home, sourceTag(ref, sha));
508
+ p.log.success(`Binário instalado em ${chalk.cyan(binPath)} (${sourceTag(ref, sha)}).`);
509
+
510
+ await finishSetup({ cfg, home, binPath, configPath, options, configAction: items[1].action,
511
+ servicePath: options.service ? items[2].path : null });
512
+ }
513
+
514
+ function run(options) {
515
+ const home = homedir();
516
+ let binPath = path.join(home, BIN_DIR, BIN_NAME + exeSuffix(process.platform));
517
+ if (!existsSync(binPath)) {
518
+ try { binPath = sh(process.platform === 'win32' ? `where ${BIN_NAME}` : `command -v ${BIN_NAME}`).split(/\r?\n/)[0]; }
519
+ catch {
520
+ p.log.error('spec-wave-agent não encontrado. Rode `spec-wave dev-agent --install` primeiro.');
521
+ process.exitCode = 1;
522
+ return;
523
+ }
524
+ }
525
+ const configPath = path.join(home, AGENT_CONFIG);
526
+ if (!existsSync(configPath)) {
527
+ p.log.error(`Config do agente não encontrada em ${configPath}. Rode \`spec-wave dev-agent --install\`.`);
528
+ process.exitCode = 1;
529
+ return;
530
+ }
531
+
532
+ p.log.step(`Executando ${chalk.dim(binPath)} — Ctrl+C encerra com checkpoint.`);
533
+ p.outro('Logs do agente abaixo.');
534
+ try {
535
+ execSync(`"${binPath}"`, {
536
+ stdio: 'inherit',
537
+ env: { ...process.env, RUST_LOG: options.debug ? 'debug' : (process.env.RUST_LOG || 'info') },
538
+ });
539
+ } catch (err) {
540
+ // Ctrl+C (SIGINT/SIGTERM) é saída normal: o agente faz checkpoint e sai.
541
+ if (err.signal === 'SIGINT' || err.signal === 'SIGTERM') return;
542
+ p.log.error(`Agente encerrou com erro: ${err.message}`);
543
+ process.exitCode = typeof err.status === 'number' ? err.status : 1;
544
+ }
545
+ }
546
+
547
+ export async function devAgent(options = {}) {
548
+ const modes = ['install', 'build', 'run'].filter((m) => options[m]);
549
+ if (modes.length > 1) {
550
+ console.error(`Use apenas um modo por vez (recebido: ${modes.map((m) => `--${m}`).join(', ')}).`);
551
+ process.exitCode = 1;
552
+ return;
553
+ }
554
+ if (modes.length === 0) {
555
+ console.error(
556
+ 'Nada a fazer. Use:\n' +
557
+ ' spec-wave dev-agent --install instala o agente a partir da release\n' +
558
+ ' spec-wave dev-agent --build clona o repo e compila localmente\n' +
559
+ ' spec-wave dev-agent --run executa o agente em foreground'
560
+ );
561
+ process.exitCode = 1;
562
+ return;
563
+ }
564
+ p.intro(chalk.bold(`spec-wave dev-agent --${modes[0]}`));
565
+ if (options.install) return install(options);
566
+ if (options.build) return build(options);
567
+ run(options);
568
+ }
@@ -11,6 +11,7 @@ import { Octokit } from '@octokit/rest';
11
11
  import { resolveToken, verifyTokenScopes } from '../api/auth.mjs';
12
12
  import { getProjectSnapshot } from '../api/github-graphql.mjs';
13
13
  import { CONFIG_FILE, WORKFLOW_FILES, getProvider, DEFAULT_PROVIDER } from '../config.mjs';
14
+ import { DEFAULT_MAX_TOKENS } from '../lib/claude.mjs';
14
15
 
15
16
  // Mesmo padrão de instanciação de github-rest.mjs, mas com o logger mudo:
16
17
  // aqui 404/403 são resultados esperados dos checks, não erros a logar.
@@ -297,6 +298,16 @@ async function checkAi(ctx) {
297
298
  if (fileAi.models && Object.keys(fileAi.models).length > 0) {
298
299
  notes.push(`Modelos por ação (ai.models): ${Object.entries(fileAi.models).map(([a, m]) => `${a}=${m}`).join(', ')}.`);
299
300
  }
301
+ // Teto de saída: quando o documento não cabe, a geração falha com
302
+ // TruncatedOutputError em vez de gravar um arquivo cortado — mostrar o valor
303
+ // resolvido evita ter que abrir o log do Action para descobri-lo.
304
+ const maxTokensNote = fileAi.maxTokens
305
+ ? `${fileAi.maxTokens} (ai.maxTokens)`
306
+ : `${DEFAULT_MAX_TOKENS} (default)`;
307
+ const byAction = fileAi.maxTokensByAction && Object.keys(fileAi.maxTokensByAction).length > 0
308
+ ? ` · por ação: ${Object.entries(fileAi.maxTokensByAction).map(([a, t]) => `${a}=${t}`).join(', ')}`
309
+ : '';
310
+ notes.push(`Teto de saída: ${maxTokensNote}${byAction}.`);
300
311
 
301
312
  let status = 'ok';
302
313
  if (process.env[provider.secret]) {
@@ -154,6 +154,20 @@ export async function generatePlan({ issueNumber }) {
154
154
  }
155
155
 
156
156
  console.log(`plan.md criado em: ${filePath}`);
157
+ } catch (err) {
158
+ // Mesmo beco sem saída do generate-spec: o gatilho é `issues: [labeled]`,
159
+ // então com a label ainda aplicada re-adicioná-la não dispara nada. Remove
160
+ // o gatilho e reporta na issue; o erro segue propagando.
161
+ // (O catch acima, na crítica adversarial, cobre só aquele trecho.)
162
+ await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:plan').catch(() => {});
163
+ await commentOnIssue(
164
+ token, owner, repo, parseInt(issueNumber, 10),
165
+ `❌ **Falha ao gerar o plan.md**\n\n` +
166
+ `\`\`\`\n${err.message}\n\`\`\`\n\n` +
167
+ `A label \`spec-wave:plan\` foi removida para destravar o gatilho — ` +
168
+ `adicione-a de novo para tentar outra vez.`
169
+ ).catch(() => {});
170
+ throw err;
157
171
  } finally {
158
172
  // Best-effort: nunca propaga erro (ver recordUsage).
159
173
  await recordUsage({ token, owner, repo, issueNumber: parseInt(issueNumber, 10), entries: usageEntries });
@@ -125,6 +125,21 @@ export async function generateSpec({ issueNumber }) {
125
125
  );
126
126
 
127
127
  console.log(`spec.md criado em: ${filePath}`);
128
+ } catch (err) {
129
+ // Sem isto a label de gatilho fica aplicada — e como o workflow dispara em
130
+ // `issues: [labeled]`, re-adicionar uma label já presente não emite evento:
131
+ // a issue vira um beco sem saída, sem comentário e sem sinal no board.
132
+ // Espelha o tratamento do tipo não suportado (acima): remove o gatilho e
133
+ // reporta na issue. O erro segue propagando para o Action falhar visível.
134
+ await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:spec').catch(() => {});
135
+ await commentOnIssue(
136
+ token, owner, repo, parseInt(issueNumber, 10),
137
+ `❌ **Falha ao gerar a spec.md**\n\n` +
138
+ `\`\`\`\n${err.message}\n\`\`\`\n\n` +
139
+ `A label \`spec-wave:spec\` foi removida para destravar o gatilho — ` +
140
+ `adicione-a de novo para tentar outra vez.`
141
+ ).catch(() => {});
142
+ throw err;
128
143
  } finally {
129
144
  // Best-effort: nunca propaga erro (ver recordUsage).
130
145
  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
@@ -52,7 +52,6 @@ export const STATUS_OPTIONS = [
52
52
  { name: '📋 Spec', color: 'YELLOW' },
53
53
  { name: '📋 Plan', color: 'YELLOW' },
54
54
  { name: '✅ Ready', color: 'GREEN' },
55
- { name: '📋 Backlog Técnico', color: 'BLUE' },
56
55
  { name: '🚧 Desenvolvimento', color: 'ORANGE' },
57
56
  { name: '👀 Code Review', color: 'PURPLE' },
58
57
  { name: '🧪 QA', color: 'PINK' },
@@ -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,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
@@ -67,16 +67,34 @@ Exemplo de `.spec-wave.json`:
67
67
 
68
68
  Exceção: se o usuário pedir explicitamente para revisar ou melhorar um documento já gerado, use o Write tool para editar o arquivo local.
69
69
 
70
+ **Nunca crie Story ou Task avulsa com `/spec-wave issue`.** Story e Task nascem do `decompose`, já na Etapa **✅ Ready** e vinculadas ao pai. Criadas à mão elas caem em **📥 Backlog**, que é a coluna de descoberta de *produto* — e ali **não aparecem em tela nenhuma** da UI do spec-wave: o inbox do PM lista só Features, a tela do Dev lê a etapa 🚧 Desenvolvimento e a fila do TL lê ✅ Ready. Se o usuário insistir numa Story/Task avulsa, crie-a **com `--parent <n>`** e, logo em seguida, avance-a para ✅ Ready — explicando por que esse passo extra é necessário.
71
+
72
+ **Nunca use `gh issue create` para criar work items.** Ele não adiciona a issue ao Project, então ela fica **sem Etapa** — e some de todas as telas da UI. Use sempre `npx @spec-wave/cli issue` (ou os atalhos `initiative` / `feature`).
73
+
70
74
  ---
71
75
 
72
76
  ## Fluxo Kanban
73
77
 
74
78
  ```
75
79
  📥 Backlog → 🎯 Priorizado → 📋 Spec → 📋 Plan → ✅ Ready
76
- 📋 Backlog Técnico → 🚧 Desenvolvimento → 👀 Code Review
80
+ → 🚧 Desenvolvimento → 👀 Code Review
77
81
  → 🧪 QA → 📋 Homologação → 🚀 Deploy → 🎉 Done
78
82
  ```
79
83
 
84
+ Essa é a sequência completa, mas **cada tipo de artefato percorre só um trecho dela** — e, principalmente, **nasce numa Etapa diferente**. Consulte esta tabela antes de criar ou mover qualquer item.
85
+
86
+ | Artefato | Nasce em | Percorre | Observações |
87
+ |----------|----------|----------|-------------|
88
+ | **Initiative / Epic** | 📥 Backlog | — | Agrupadores. Não têm fluxo próprio; acompanham os filhos. |
89
+ | **Feature** | 📥 Backlog | 🎯 Priorizado → 📋 Spec → 📋 Plan → ✅ Ready → 🚧 Desenvolvimento → 👀 Code Review → 🧪 QA → 📋 Homologação → 🚀 Deploy → 🎉 Done | Fica parada em **✅ Ready** esperando um dev assumir — é ali que ela aparece na fila do TL. Só avança para 👀 Code Review quando **TODAS** as suas Stories já estiverem lá. |
90
+ | **Story** | **✅ Ready** (criada pelo `decompose`) | 🚧 Desenvolvimento → 👀 Code Review → 🧪 QA → 📋 Homologação → 🎉 Done | **Nunca nasce em 📥 Backlog.** |
91
+ | **Task** | **✅ Ready** (criada pelo `decompose`) | 🚧 Desenvolvimento → 🎉 Done | **Não** passa por Code Review, QA nem Homologação — só `task start` e `task done`. |
92
+ | **RFC** | 📥 Backlog | decompõe direto em **Tasks** (que nascem em ✅ Ready) | Não usa spec/plan. |
93
+ | **Bug** | 📥 Backlog | 🚧 Desenvolvimento → 🎉 Done | Ao aprovar, vai direto para Done (não passa por Homologação). |
94
+ | **Spike** | 📥 Backlog | **movido só à mão pelo usuário** | Nunca avance a Etapa de um Spike por conta própria. |
95
+
96
+ **Regra da Etapa:** a Etapa **só avança, nunca retrocede**. O campo **Status** (Todo / In Progress / Done) mede o progresso *dentro* da Etapa e reinicia a cada avanço. Prefira sempre os comandos da CLI (`task start|done`, `story review`) a mutações manuais no board — eles embutem essas regras.
97
+
80
98
  Labels de gatilho:
81
99
  - `spec-wave:spec` → dispara `generate-spec.yml` → gera `spec.md` (especificação funcional, primeiro)
82
100
  - `spec-wave:plan` → dispara `generate-plan.yml` → gera `plan.md` (plano técnico, a partir da spec)
@@ -115,7 +133,9 @@ Esta skill é um **wrapper** da CLI `@spec-wave/cli`, sempre invocada como `npx
115
133
  | `--priority <p>` | string | **Opcional.** `P0`, `P1`, `P2` ou `P3`. Omita se o usuário não pediu — a prioridade fica `null` (sem prioridade). Nunca atribua por conta própria. |
116
134
  | `--area <area>` | string | `Frontend`, `Backend`, `Mobile`, `Infra`, `DevOps` ou `Data`. |
117
135
 
118
- > Faz tudo: cria a issue (label de tipo — e de prioridade **apenas se `--priority` for informado**), vincula ao parent como sub-issue, adiciona ao Project e define os campos **Etapa = 📥 Backlog**, **Work Item Type**, **Area** e, **só se informada, Priority**. Grava `Parent: #N` no corpo. Lê o Project do `.spec-wave.json`. **Não use `gh issue create` direto** — ele não adiciona ao board nem vincula o parent.
136
+ > Faz tudo: cria a issue (label de tipo — e de prioridade **apenas se `--priority` for informado**), vincula ao parent como sub-issue, adiciona ao Project e define os campos **Etapa**, **Work Item Type**, **Area** e, **só se informada, Priority**. Grava `Parent: #N` no corpo. Lê o Project do `.spec-wave.json`. **Não use `gh issue create` direto** — ele não adiciona ao board nem vincula o parent.
137
+ >
138
+ > ⚠️ **A Etapa inicial é sempre 📥 Backlog, para qualquer `--type`.** Isso é o correto para **Initiative, Epic, Feature, RFC, Bug e Spike**. Para **Story e Task está errado** — elas pertencem a ✅ Ready (veja a *Regra fundamental*): prefira criá-las via `decompose`; se criar à mão, avance a Etapa logo depois.
119
139
 
120
140
  ### `@spec-wave/cli initiative` — atalho de `issue --type initiative`
121
141
  Cria o nó raiz da hierarquia (agrupa Epics). Mesmas flags do `issue` exceto `--type` (fixo em `initiative`) e `--parent` (Initiative é raiz, não tem pai).
@@ -180,7 +200,7 @@ Mesmas flags do `issue` (exceto `--type`, fixo em `feature`). Mantido para o flu
180
200
  > Diferente dos quatro acima, `implement` roda **localmente** (lê `.spec-wave.json`, como `issue`), não por Action. Detecta o tipo da issue: **Feature** → lista as Stories (sub-issues), **ordena topologicamente pelas dependências** (`Depende de:` + *blocked by*), **pula** as já em 👀 Code Review+ (listadas no contexto como "não tocar") e monta **um único** contexto com todas as pendentes (cada uma com suas Tasks), acionando o spec-kit **uma vez** com `{issue}/{type}/{title}` da Feature — **ciclo de dependências entre Stories pendentes aborta o comando (exit 1)**; **Story** → coleta todas as Tasks (sub-issues) e aciona o spec-kit uma única vez; **Task** → só aquela task. Monta o contexto em `.spec-wave/implement-<n>.md` e chama o comando configurado em `specKit.command` (no `.spec-wave.json`) ou na env `SPEC_WAVE_IMPLEMENT_CMD`. Placeholders disponíveis no template: `{tasksFile} {specFile} {planFile} {issue} {type} {title}`. Se nada estiver configurado, ele apenas monta o contexto e mostra como configurar (não executa). O contexto inclui os **comentários da issue**, um **digest do código recente** e um **aviso de dependências pendentes** quando a issue depende (linha `Depende de: #N` ou relação nativa *blocked by*) de outra que ainda não foi concluída — nesse caso, confirme com o usuário antes de seguir. Inclui também instruções para o agente implementar as Tasks **sequencialmente, uma por vez** (nunca duas com Status "In Progress" ao mesmo tempo): cada Task usa o **Status** (In Progress) *dentro* da Etapa 🚧 Desenvolvimento e, **ao concluir, avança para a Etapa 🎉 Done com Status Done**. **Ao concluir toda a Story**: fazer o commit, abrir o PR e **avançar a Etapa da Story para 👀 Code Review** (Status → Todo) — as Tasks já estão em 🎉 Done. A **Feature só avança** para Code Review quando **TODAS as suas Stories** já estiverem em Code Review — enquanto houver Story pendente, a Feature fica em 🚧 Desenvolvimento. Etapa só avança (nunca volta); Status mede o progresso dentro da etapa.
181
201
 
182
202
  ### `@spec-wave/cli doctor` — preflight de auth e configuração (comando LOCAL)
183
- Sem flags. Roda um checklist de diagnóstico no repositório atual: token GitHub (e a fonte dele), escopos (`repo`, `project`, `workflow` — com degradação para checks funcionais em fine-grained PATs), conta ativa do `gh` vs. owner, `.spec-wave.json` (campos e sincronia com o Project real), acesso ao repositório, configuração de IA (provider/modelo/`ai.models` + secrets do Actions), **spec-kit** (`specKit.command` / env `SPEC_WAVE_IMPLEMENT_CMD` — se ausente, avisa e sugere exemplos por agente: Claude Code, opencode, Codex, Copilot CLI, Kiro CLI, Qwen Code) e presença dos workflows.
203
+ Sem flags. Roda um checklist de diagnóstico no repositório atual: token GitHub (e a fonte dele), escopos (`repo`, `project`, `workflow` — com degradação para checks funcionais em fine-grained PATs), conta ativa do `gh` vs. owner, `.spec-wave.json` (campos e sincronia com o Project real), acesso ao repositório, configuração de IA (provider/modelo/`ai.models`/teto de saída + secrets do Actions), **spec-kit** (`specKit.command` / env `SPEC_WAVE_IMPLEMENT_CMD` — se ausente, avisa e sugere exemplos por agente: Claude Code, opencode, Codex, Copilot CLI, Kiro CLI, Qwen Code) e presença dos workflows.
184
204
 
185
205
  > Saída: `✓` ok, `✗` problema confirmado, `!` não verificável (best-effort — falha de rede nunca derruba o doctor). **Exit 1** se houver algum `✗`. **Quando rodar:** no início de uma sessão de trabalho, ou sempre que aparecer um erro estranho (ex.: **404 ao criar issues** — causa típica: token sem acesso ao repo/org, que o doctor aponta). É o primeiro passo de troubleshooting — prefira-o a depurar `gh api` na mão.
186
206
 
@@ -253,6 +273,25 @@ Cada ação de IA (`spec`, `plan`, `decompose`, `critique`) pode usar um modelo
253
273
 
254
274
  Edite o bloco `ai` no `.spec-wave.json` (e commite) — o `doctor` mostra o provider, o modelo e os overrides de `ai.models` resolvidos.
255
275
 
276
+ ### Teto de saída (`ai.maxTokens`) e truncamento
277
+
278
+ O teto de tokens de saída é **32.768** por padrão, ajustável globalmente por `ai.maxTokens` e por ação em `ai.maxTokensByAction` (mesma forma de `model`/`models`; `SPEC_WAVE_MAX_TOKENS` na env tem precedência):
279
+
280
+ ```json
281
+ {
282
+ "ai": {
283
+ "maxTokens": 32768,
284
+ "maxTokensByAction": { "spec": 49152 }
285
+ }
286
+ }
287
+ ```
288
+
289
+ **Quando a saída não cabe no teto, a geração FALHA — nada é gravado.** Os dois provedores sinalizam o corte (`finish_reason: "length"` no OpenRouter, `stop_reason: "max_tokens"` na Anthropic) e a CLI transforma isso em erro: um documento cortado no meio de uma frase passaria na validação de seções e valeria menos que documento nenhum. O erro é comentado na issue e a label de gatilho é removida, então basta corrigir e re-aplicar a label.
290
+
291
+ Se acontecer, as saídas são: **aumentar o teto** (`ai.maxTokens`) ou **reduzir o tamanho da issue de origem** — corpo muito grande gera documento muito grande. Modelos com raciocínio (Opus 4.7+, deepseek-r1) gastam parte do teto "pensando" antes de escrever, então o teto precisa cobrir raciocínio + documento.
292
+
293
+ O `validate` também recusa documento com sinal objetivo de corte (bloco de código não fechado, parêntese aberto na última linha) — rede de segurança para documento editado à mão ou gerado por versão antiga da CLI.
294
+
256
295
  ---
257
296
 
258
297
  ## Sub-comandos
@@ -314,7 +353,7 @@ Configura o spec-wave no repositório. Você dirige o `init` com flags — **nun
314
353
 
315
354
  ### `/spec-wave issue <tipo> <descrição>` · `/spec-wave initiative <descrição>` · `/spec-wave feature <descrição>`
316
355
 
317
- Crie um work item tipado (Initiative/Epic/Feature/Story/Task/...) já adicionado ao board em **📥 Backlog**, opcionalmente como sub-issue de um parent.
356
+ Crie um work item tipado (Initiative/Epic/Feature/Story/Task/...) já adicionado ao board, opcionalmente como sub-issue de um parent. A Etapa inicial é **📥 Backlog** — correta para Initiative, Epic, Feature, RFC, Bug e Spike. **Story e Task não devem ser criadas por aqui** (nascem do `decompose`, em ✅ Ready — veja a *Regra fundamental*).
318
357
 
319
358
  **Hierarquia típica:** Initiative → Epic → Feature → Story → Task. A **Initiative** é o nó raiz e agrupa Epics. Use `--parent <n>` para criar como sub-issue do nível acima (ex.: um Epic filho de uma Initiative, ou uma Story filha de uma Feature). O GitHub mostra o parent na issue filha e vice-versa; a CLI ainda grava `Parent: #N` no corpo.
320
359
 
@@ -334,6 +373,7 @@ Crie um work item tipado (Initiative/Epic/Feature/Story/Task/...) já adicionado
334
373
  ```
335
374
  Para Features, pode usar o atalho `npx @spec-wave/cli@latest feature --title ...` (equivale a `--type feature`).
336
375
  A CLI cria a issue (label de tipo — e de prioridade **apenas se `--priority` for informado**), vincula como sub-issue do parent, adiciona ao Project e define Etapa = 📥 Backlog + Work Item Type + Area (+ Priority só se informada). **Não use `gh issue create`** (não adiciona ao board nem vincula o parent).
376
+ **Se o tipo for `story` ou `task`**, avise o usuário que o caminho normal é o `decompose` e, se ele confirmar mesmo assim, avance a Etapa para ✅ Ready depois de criar — senão o item fica invisível na UI.
337
377
  3. Informe o número criado e o vínculo com o pai (se houver).
338
378
  4. Para Features: "Quando quiser iniciar, mova para **📋 Spec** e use `/spec-wave spec <número>` para gerar a especificação funcional (o plano técnico vem depois)".
339
379
 
@@ -456,7 +496,7 @@ Valida que spec.md e plan.md estão completos e a Feature pode avançar.
456
496
  2. Informe: "Validação iniciada. O workflow verificará se spec.md e plan.md contêm todas as seções obrigatórias."
457
497
  3. Se a validação falhar, o workflow comentará os problemas na issue e adicionará automaticamente `spec-wave:spec`. Informe o usuário para corrigir e tentar novamente.
458
498
  4. **Se a issue tiver a label `spec-wave:critique-failed`**, a validação falha de imediato: a crítica adversarial apontou contradições graves (comentário 🔎 na issue). Siga o fluxo de resolução da seção *Crítica adversarial*: corrigir os documentos → remover a label → re-aplicar `spec-wave:ready`.
459
- 5. Se passar, oriente: "Feature validada! Mova o card para **✅ Ready** e depois para **📋 Backlog Técnico** para iniciar a decomposição."
499
+ 5. Se passar, oriente: "Feature validada! Mova o card para **✅ Ready** e use `/spec-wave decompose <número>` para gerar as Stories."
460
500
 
461
501
  ---
462
502