@spec-wave/cli 0.10.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/bin/spec-wave.mjs +20 -0
- package/package.json +2 -2
- package/src/api/github-graphql.mjs +4 -0
- package/src/api/github-rest.mjs +13 -0
- package/src/commands/decompose.mjs +183 -19
- package/src/commands/dev-agent.mjs +568 -0
- package/src/commands/doctor.mjs +11 -0
- package/src/commands/generate-plan.mjs +21 -1
- package/src/commands/generate-spec.mjs +24 -1
- package/src/commands/validate.mjs +9 -0
- package/src/config.mjs +5 -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/lib/force.mjs +34 -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 +60 -7
|
@@ -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]) {
|
|
@@ -8,6 +8,7 @@ import { generateDocument } from '../lib/claude.mjs';
|
|
|
8
8
|
import { runCritique } from '../lib/critique.mjs';
|
|
9
9
|
import { recordUsage } from '../lib/usage-report.mjs';
|
|
10
10
|
import { slugify } from '../lib/slugify.mjs';
|
|
11
|
+
import { isForced, consumeForceLabel } from '../lib/force.mjs';
|
|
11
12
|
import { buildTechContext } from '../lib/tech-context.mjs';
|
|
12
13
|
|
|
13
14
|
// Aviso anexado ao comentário quando o lint de idioma ainda reprova após o
|
|
@@ -41,7 +42,7 @@ Regras OBRIGATÓRIAS:
|
|
|
41
42
|
- Forneça detalhes acionáveis: caminhos exatos de endpoints, nomes de DTOs, constraints de banco.
|
|
42
43
|
- Responda APENAS com o conteúdo do plan.md, sem texto adicional.`;
|
|
43
44
|
|
|
44
|
-
export async function generatePlan({ issueNumber }) {
|
|
45
|
+
export async function generatePlan({ issueNumber, force = false }) {
|
|
45
46
|
const token = await resolveToken();
|
|
46
47
|
const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
47
48
|
|
|
@@ -69,6 +70,11 @@ export async function generatePlan({ issueNumber }) {
|
|
|
69
70
|
return;
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
// Ver generate-spec: não há guard aqui — a label já regera e sobrescreve.
|
|
74
|
+
const forced = isForced({ labels: issue.labels || [], flag: force });
|
|
75
|
+
await consumeForceLabel(token, owner, repo, parseInt(issueNumber, 10));
|
|
76
|
+
if (forced) console.log('Modo forçado ativo — plan.md será regerado (sobrescreve o existente).');
|
|
77
|
+
|
|
72
78
|
const slug = slugify(issue.title);
|
|
73
79
|
const featureDir = `docs/features/${slug}`;
|
|
74
80
|
const filePath = `${featureDir}/plan.md`;
|
|
@@ -154,6 +160,20 @@ export async function generatePlan({ issueNumber }) {
|
|
|
154
160
|
}
|
|
155
161
|
|
|
156
162
|
console.log(`plan.md criado em: ${filePath}`);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
// Mesmo beco sem saída do generate-spec: o gatilho é `issues: [labeled]`,
|
|
165
|
+
// então com a label ainda aplicada re-adicioná-la não dispara nada. Remove
|
|
166
|
+
// o gatilho e reporta na issue; o erro segue propagando.
|
|
167
|
+
// (O catch acima, na crítica adversarial, cobre só aquele trecho.)
|
|
168
|
+
await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:plan').catch(() => {});
|
|
169
|
+
await commentOnIssue(
|
|
170
|
+
token, owner, repo, parseInt(issueNumber, 10),
|
|
171
|
+
`❌ **Falha ao gerar o plan.md**\n\n` +
|
|
172
|
+
`\`\`\`\n${err.message}\n\`\`\`\n\n` +
|
|
173
|
+
`A label \`spec-wave:plan\` foi removida para destravar o gatilho — ` +
|
|
174
|
+
`adicione-a de novo para tentar outra vez.`
|
|
175
|
+
).catch(() => {});
|
|
176
|
+
throw err;
|
|
157
177
|
} finally {
|
|
158
178
|
// Best-effort: nunca propaga erro (ver recordUsage).
|
|
159
179
|
await recordUsage({ token, owner, repo, issueNumber: parseInt(issueNumber, 10), entries: usageEntries });
|