@spec-wave/cli 0.10.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/bin/spec-wave.mjs +17 -0
- package/package.json +2 -2
- package/src/commands/dev-agent.mjs +568 -0
- package/src/commands/doctor.mjs +11 -0
- package/src/commands/generate-plan.mjs +14 -0
- package/src/commands/generate-spec.mjs +15 -0
- package/src/commands/validate.mjs +9 -0
- package/src/lib/claude.mjs +216 -19
- package/src/lib/critique.mjs +5 -1
- package/src/lib/doc-completeness.mjs +54 -0
- package/src/templates/agent/dev.specwave.agent.plist +17 -0
- package/src/templates/agent/spec-wave-agent.service +18 -0
- package/src/templates/skill/SKILL.md +43 -3
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.
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -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/lib/claude.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
70
|
-
// resposta, então o teto precisa
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
397
|
+
const err = new Error(`OpenRouter API ${res.status}: ${body}`);
|
|
398
|
+
err.status = res.status;
|
|
399
|
+
throw err;
|
|
236
400
|
}
|
|
237
401
|
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/lib/critique.mjs
CHANGED
|
@@ -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
|
-
|
|
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,6 +67,10 @@ 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
|
|
@@ -77,6 +81,20 @@ Exceção: se o usuário pedir explicitamente para revisar ou melhorar um docume
|
|
|
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
|
|
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
|
|
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
|
|
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
|
|