terminal-smart-cli 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -0
- package/bin/ts.js +1267 -0
- package/lib/acp.js +191 -0
- package/lib/agent.js +470 -0
- package/lib/api.js +77 -0
- package/lib/compactor.js +152 -0
- package/lib/config.js +20 -0
- package/lib/eval.js +243 -0
- package/lib/hooks.js +72 -0
- package/lib/i18n.js +294 -0
- package/lib/memoria.js +38 -0
- package/lib/meta.js +1199 -0
- package/lib/router.js +82 -0
- package/lib/skills.js +81 -0
- package/lib/ssh.js +93 -0
- package/lib/tools.js +397 -0
- package/lib/ui.js +106 -0
- package/package.json +35 -0
package/lib/meta.js
ADDED
|
@@ -0,0 +1,1199 @@
|
|
|
1
|
+
// ts meta — MISSÃO: objetivo grande rodando em rodadas até terminar (modo noturno).
|
|
2
|
+
// Receita do artigo de harnesses da Anthropic (a mesma do /meta do app):
|
|
3
|
+
// 1) o objetivo vira um CHECKLIST de itens verificáveis persistido em .ts-meta.json
|
|
4
|
+
// 2) cada rodada roda o agente local com CONTEXTO ZERADO atacando 1 item
|
|
5
|
+
// 3) um marcador barato (flash-lite) só aprova item com EVIDÊNCIA no resultado
|
|
6
|
+
// 4) "terminou" é MECÂNICO: todos os itens passaram — nunca opinião do modelo
|
|
7
|
+
// Sobrevive a queda (estado em arquivo, `ts meta` retoma), respeita teto de
|
|
8
|
+
// créditos/rodadas, e nunca trava: item que falha 2x é marcado como bloqueado.
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const cp = require('child_process');
|
|
12
|
+
const compactor = require('./compactor');
|
|
13
|
+
const { api, ApiError } = require('./api');
|
|
14
|
+
// Teto de IA estourado (free/plano) no gateway → 402 (ou 429 com type cost_cap). Os helpers de
|
|
15
|
+
// IA da missão DEVEM lançar (não retornar vazio em silêncio), senão a missão desperdiça rodadas
|
|
16
|
+
// fingindo trabalhar. O run() pausa (estado salvo = resumível) e mostra CTA de upgrade.
|
|
17
|
+
function _capGuard(res, j) {
|
|
18
|
+
const cap = res.status === 402 || (res.status === 429 && /cost_cap|tenant_cost|teto|insufficient|quota|no_credits/i.test(JSON.stringify((j && j.error) || j || '')));
|
|
19
|
+
if (cap) throw new ApiError((j && (j.error?.message || j.message)) || 'no_credits', { status: res.status, code: 'no_credits' });
|
|
20
|
+
}
|
|
21
|
+
const agent = require('./agent');
|
|
22
|
+
|
|
23
|
+
const FILE = '.ts-meta.json';
|
|
24
|
+
const MAX_ATTEMPTS_PER_ITEM = 2;
|
|
25
|
+
const MAX_ESCALATIONS = 3; // quantas vezes chamamos o modelo CARO "pensador"
|
|
26
|
+
const STAGNATION_LIMIT = 3; // rodadas SEM nenhum progresso → watchdog corta
|
|
27
|
+
const MAX_VISUAL_FIXES = 5; // rodadas de polimento VISUAL (o olho crítico) — evita perfeccionismo infinito
|
|
28
|
+
const MAX_VISUAL_FIXES_FLUTTER = 3; // Flutter: rebuild é MUITO mais pesado/lento → teto menor de polimento (economia)
|
|
29
|
+
const VISUAL_ESCALATE_AT = 2; // após N reprovações visuais, a MÃO do fix vira o modelo forte (grok) — o deepseek erra layout complexo
|
|
30
|
+
const MAX_BUILD_FIXES = 12; // rodadas extras de correção de build antes de desistir
|
|
31
|
+
|
|
32
|
+
// Detecta se o projeto é COMPILÁVEL e devolve como compilar + como saber que passou.
|
|
33
|
+
// Procura recursivamente (modelos costumam aninhar em uma subpasta tipo MultiApps/).
|
|
34
|
+
function detectBuild(root, depth = 0) {
|
|
35
|
+
let list; try { list = fs.readdirSync(root, { withFileTypes: true }); } catch (_) { return null; }
|
|
36
|
+
const has = (n) => list.some(e => e.name === n);
|
|
37
|
+
if (has('gradlew.bat') || has('gradlew') || has('settings.gradle') || has('settings.gradle.kts')) {
|
|
38
|
+
const gw = process.platform === 'win32' ? '"' + path.join(root, 'gradlew.bat') + '"' : path.join(root, 'gradlew');
|
|
39
|
+
return { cwd: root, cmd: gw + ' assembleDebug --no-daemon', artifact: 'app/build/outputs/apk/debug/app-debug.apk', kind: 'android' };
|
|
40
|
+
}
|
|
41
|
+
if (has('pubspec.yaml')) return { cwd: root, cmd: 'flutter build apk --debug', artifact: 'build/app/outputs/flutter-apk/app-debug.apk', kind: 'flutter' };
|
|
42
|
+
if (has('package.json')) {
|
|
43
|
+
try {
|
|
44
|
+
const pj = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
45
|
+
const sc = pj.scripts || {};
|
|
46
|
+
if (sc.build) return { cwd: root, cmd: 'npm run build', artifact: null, kind: 'node' };
|
|
47
|
+
// projeto WEB (server sem compilação): tem start/dev OU um server.js/app.js/index.js → gate WEB
|
|
48
|
+
const serverFile = ['server.js', 'app.js', 'index.js', 'src/server.js', 'src/index.js', 'src/app.js'].find(f => has(f) || fs.existsSync(path.join(root, f)));
|
|
49
|
+
if (sc.start || sc.dev || serverFile) {
|
|
50
|
+
const startCmd = sc.start ? 'npm start' : sc.dev ? 'npm run dev' : 'node ' + serverFile;
|
|
51
|
+
return { cwd: root, cmd: startCmd, artifact: null, kind: 'web', startCmd, port: _detectPort(root, serverFile) };
|
|
52
|
+
}
|
|
53
|
+
} catch (_) {}
|
|
54
|
+
}
|
|
55
|
+
if (depth < 3) for (const e of list) if (e.isDirectory() && !['build', '.gradle', 'node_modules', '.git'].includes(e.name)) { const r = detectBuild(path.join(root, e.name), depth + 1); if (r) return r; }
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// O HARNESS é dono da toolchain: modelos fazem o CÓDIGO mas não provisionam o
|
|
60
|
+
// gradle-wrapper.jar (binário) nem lidam com Java novo demais. Antes de compilar,
|
|
61
|
+
// garante wrapper + gradlew + JDK compatível — assim o build depende só do código.
|
|
62
|
+
function _findJdk(maxMajor) {
|
|
63
|
+
const os = require('os');
|
|
64
|
+
const cands = [
|
|
65
|
+
'C:/Program Files/Android/Android Studio/jbr',
|
|
66
|
+
'C:/Program Files/Android/Android Studio/jre',
|
|
67
|
+
process.env.JAVA_HOME_17, process.env.JAVA_HOME_21,
|
|
68
|
+
].filter(Boolean);
|
|
69
|
+
for (const p of cands) { try { if (fs.existsSync(path.join(p, 'bin', 'java.exe')) || fs.existsSync(path.join(p, 'bin', 'java'))) return p; } catch (_) {} }
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
function _download(url, dest) {
|
|
73
|
+
try { cp.execSync(`powershell -NoProfile -Command "Invoke-WebRequest -Uri '${url}' -OutFile '${dest}' -UseBasicParsing"`, { stdio: 'ignore', timeout: 60000 }); return fs.existsSync(dest) && fs.statSync(dest).size > 5000; }
|
|
74
|
+
catch (_) { try { cp.execSync(`curl -fsSL "${url}" -o "${dest}"`, { stdio: 'ignore', timeout: 60000 }); return fs.existsSync(dest); } catch (__) { return false; } }
|
|
75
|
+
}
|
|
76
|
+
// Acha o flutter.bat instalado (PATH ou locais comuns) ou null.
|
|
77
|
+
function _findFlutter() {
|
|
78
|
+
const home = require('os').homedir();
|
|
79
|
+
const cands = ['C:/flutter/bin/flutter.bat', 'C:/src/flutter/bin/flutter.bat', path.join(home, 'flutter/bin/flutter.bat'), path.join(home, 'AppData/Local/flutter/bin/flutter.bat')];
|
|
80
|
+
for (const c of cands) if (fs.existsSync(c)) return c;
|
|
81
|
+
try { const w = cp.execSync('where flutter', { encoding: 'utf8', timeout: 8000 }).split('\n')[0].trim(); if (w && fs.existsSync(w)) return w; } catch (_) {}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
// Garante o Flutter SDK: se não achar, INSTALA (git clone stable) — o harness provisiona.
|
|
85
|
+
function ensureFlutter(b, onAlert) {
|
|
86
|
+
let fb = _findFlutter();
|
|
87
|
+
if (!fb) {
|
|
88
|
+
const target = 'C:/flutter';
|
|
89
|
+
try {
|
|
90
|
+
if (onAlert) onAlert({ type: 'setup', text: '📥 ts: Flutter SDK não encontrado — instalando (git clone stable, ~1GB, pode levar alguns minutos)…' });
|
|
91
|
+
cp.execSync(`git clone https://github.com/flutter/flutter.git -b stable --depth 1 "${target}"`, { stdio: 'ignore', timeout: 900000 });
|
|
92
|
+
fb = path.join(target, 'bin', 'flutter.bat');
|
|
93
|
+
} catch (_) {}
|
|
94
|
+
}
|
|
95
|
+
if (fb && fs.existsSync(fb)) {
|
|
96
|
+
try { cp.execSync(`"${fb}" --version`, { stdio: 'ignore', timeout: 300000 }); } catch (_) {} // 1º run baixa o dart sdk
|
|
97
|
+
b.cmd = `"${fb}" build apk --debug`;
|
|
98
|
+
b.artifact = 'build/app/outputs/flutter-apk/app-debug.apk';
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
function ensureToolchain(b, onAlert) {
|
|
104
|
+
if (b.kind === 'flutter') { ensureFlutter(b, onAlert); return; }
|
|
105
|
+
if (b.kind !== 'android') return;
|
|
106
|
+
const root = b.cwd, GV = '8.10.2';
|
|
107
|
+
try {
|
|
108
|
+
// 1) wrapper properties
|
|
109
|
+
const wdir = path.join(root, 'gradle', 'wrapper');
|
|
110
|
+
fs.mkdirSync(wdir, { recursive: true });
|
|
111
|
+
const props = path.join(wdir, 'gradle-wrapper.properties');
|
|
112
|
+
if (!fs.existsSync(props)) fs.writeFileSync(props, `distributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\ndistributionUrl=https\\://services.gradle.org/distributions/gradle-${GV}-bin.zip\nzipStoreBase=GRADLE_USER_HOME\nzipStorePath=wrapper/dists\n`);
|
|
113
|
+
// 2) wrapper jar (binário — baixa se faltar ou for inválido)
|
|
114
|
+
const jar = path.join(wdir, 'gradle-wrapper.jar');
|
|
115
|
+
if (!fs.existsSync(jar) || fs.statSync(jar).size < 10000) _download(`https://raw.githubusercontent.com/gradle/gradle/v${GV}/gradle/wrapper/gradle-wrapper.jar`, jar);
|
|
116
|
+
// 3) gradlew.bat (texto — baixa se faltar)
|
|
117
|
+
const gwbat = path.join(root, 'gradlew.bat');
|
|
118
|
+
if (!fs.existsSync(gwbat)) _download(`https://raw.githubusercontent.com/gradle/gradle/v${GV}/gradlew.bat`, gwbat);
|
|
119
|
+
// 4) local.properties com sdk.dir em BARRA NORMAL (armadilha clássica: barra
|
|
120
|
+
// invertida simples em .properties é escape → "sintaxe do nome incorreta").
|
|
121
|
+
const sdk = (process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT || path.join(require('os').homedir(), 'AppData', 'Local', 'Android', 'Sdk')).replace(/\\/g, '/');
|
|
122
|
+
const lp = path.join(root, 'local.properties');
|
|
123
|
+
let needSdk = true;
|
|
124
|
+
try { if (fs.existsSync(lp)) { const cur = fs.readFileSync(lp, 'utf8'); const m = cur.match(/^sdk\.dir=(.+)$/m); if (m && !m[1].includes('\\') && fs.existsSync(m[1].trim())) needSdk = false; } } catch (_) {}
|
|
125
|
+
if (needSdk && fs.existsSync(sdk)) fs.writeFileSync(lp, 'sdk.dir=' + sdk + '\n');
|
|
126
|
+
// 5) JDK compatível: Gradle 8.10 não roda em Java muito novo. Considera SÓ linha
|
|
127
|
+
// ATIVA de org.gradle.java.home (ignora comentada com #).
|
|
128
|
+
const gp = path.join(root, 'gradle.properties');
|
|
129
|
+
let g = fs.existsSync(gp) ? fs.readFileSync(gp, 'utf8') : '';
|
|
130
|
+
const hasActiveJava = /^\s*org\.gradle\.java\.home\s*=/m.test(g);
|
|
131
|
+
if (!hasActiveJava) { const jdk = _findJdk(); if (jdk) { g += `\norg.gradle.java.home=${jdk.replace(/\\/g, '/')}\n`; fs.writeFileSync(gp, g); g = fs.readFileSync(gp, 'utf8'); } }
|
|
132
|
+
if (!/android\.useAndroidX\s*=\s*true/.test(g)) fs.appendFileSync(gp, 'android.useAndroidX=true\n');
|
|
133
|
+
// recomputa o cmd com o gradlew agora garantido
|
|
134
|
+
b.cmd = (process.platform === 'win32' ? '"' + gwbat + '"' : path.join(root, 'gradlew')) + ' assembleDebug --no-daemon';
|
|
135
|
+
} catch (_) {}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Roda o build de verdade e devolve {ok, out} — out compactado pro contexto.
|
|
139
|
+
// IMPORTANTE: o gradlew.bat chamado com aspas via cmd às vezes retorna EXIT 0 mesmo
|
|
140
|
+
// falhando (armadilha de batch no Windows). Por isso a falha é detectada pela SAÍDA
|
|
141
|
+
// (BUILD FAILED / error:) e pela existência do artefato — nunca só pelo exit code.
|
|
142
|
+
function runBuild(b) {
|
|
143
|
+
if (b.kind === 'web') return { ok: true, out: 'WEB (sem compilação — verificado pelo gate web)' }; // web não compila; o gate web sobe o servidor
|
|
144
|
+
let out = '', threw = false;
|
|
145
|
+
// 2>&1: mescla stderr no stdout — os erros do aapt2 vão pro stderr, e como o batch
|
|
146
|
+
// pode retornar exit 0 mesmo falhando, sem isso perderíamos os erros reais.
|
|
147
|
+
try { out = cp.execSync(b.cmd + ' 2>&1', { cwd: b.cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 6e7, timeout: 360000 }); }
|
|
148
|
+
catch (e) { threw = true; out = (e.stdout || '') + '\n' + (e.stderr || ''); }
|
|
149
|
+
const failedByText = /BUILD FAILED|FAILURE:|^\s*error:|^e: |does not contain a Gradle build|resource .* not found/m.test(out);
|
|
150
|
+
const apkExists = b.artifact ? fs.existsSync(path.join(b.cwd, b.artifact)) : true;
|
|
151
|
+
if (!threw && !failedByText && apkExists) return { ok: true, out: 'BUILD OK' };
|
|
152
|
+
const errs = out.split('\n').filter(l => /error:|^e: |resource .* not found|Unresolved|Caused by|What went wrong|does not contain|FAILURE|cannot find|BUILD FAILED/i.test(l)).slice(0, 40).join('\n');
|
|
153
|
+
return { ok: false, out: compactor.stripAnsi(errs || out || 'build falhou sem saída legível').slice(0, 3000) };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Descobre a porta que o servidor web escuta (procura listen(NNNN) nos arquivos de entrada).
|
|
157
|
+
function _detectPort(root, serverFile) {
|
|
158
|
+
const files = [serverFile, 'server.js', 'app.js', 'index.js', 'src/server.js', 'src/index.js', 'src/app.js', '.env'].filter(Boolean).map(f => path.join(root, f));
|
|
159
|
+
for (const f of files) {
|
|
160
|
+
try {
|
|
161
|
+
const t = fs.readFileSync(f, 'utf8');
|
|
162
|
+
// pega '.listen(3777' | '.listen(process.env.PORT || 3777' | 'PORT = 3777' | 'PORT = process.env.PORT || 3777' | 'PORT=3777' (.env)
|
|
163
|
+
const m = t.match(/\.listen\s*\(\s*(?:process\.env\.\w+\s*\|\|\s*)?['"`]?(\d{2,5})/)
|
|
164
|
+
|| t.match(/(?:const|let|var)?\s*PORTA?\s*[=:]\s*(?:process\.env\.\w+\s*\|\|\s*)?['"`]?(\d{2,5})/i)
|
|
165
|
+
|| t.match(/\bPORTA?\s*=\s*(\d{2,5})/i);
|
|
166
|
+
if (m) return +m[1];
|
|
167
|
+
} catch (_) {}
|
|
168
|
+
}
|
|
169
|
+
return 3000;
|
|
170
|
+
}
|
|
171
|
+
function _findChrome() {
|
|
172
|
+
const home = require('os').homedir();
|
|
173
|
+
const cands = ['C:/Program Files/Google/Chrome/Application/chrome.exe', 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe', path.join(home, 'AppData/Local/Google/Chrome/Application/chrome.exe'), '/usr/bin/google-chrome', '/usr/bin/chromium-browser', '/usr/bin/chromium', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'];
|
|
174
|
+
for (const c of cands) { try { if (fs.existsSync(c)) return c; } catch (_) {} }
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Extrai credenciais do SEED direto do código (o seed cria um usuário demo com email+senha
|
|
179
|
+
// EM TEXTO no script). Serve pro gate web LOGAR e verificar as telas autenticadas. Heurística:
|
|
180
|
+
// acha um email quotado (prefere demo/teste/admin) e uma senha quotada por perto.
|
|
181
|
+
function _seedCreds(cwd) {
|
|
182
|
+
const cands = ['server.js', 'app.js', 'index.js', 'seed.js', 'init.js', 'init_db.js', 'seed_db.js',
|
|
183
|
+
'database/seed.js', 'database/init.js', 'database/database.js', 'database/models.js',
|
|
184
|
+
'db/seed.js', 'db/init.js', 'src/server.js', 'src/seed.js', 'src/index.js', 'src/app.js'];
|
|
185
|
+
let txt = '';
|
|
186
|
+
for (const f of cands) { try { const p = path.join(cwd, f); if (fs.existsSync(p)) txt += '\n/*' + f + '*/\n' + fs.readFileSync(p, 'utf8'); } catch (_) {} }
|
|
187
|
+
if (!txt) return null;
|
|
188
|
+
const emails = [...txt.matchAll(/['"`]([a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,})['"`]/gi)].map(m => m[1]);
|
|
189
|
+
if (!emails.length) return null;
|
|
190
|
+
const email = emails.find(e => /demo|teste|test|admin|exemplo|user/i.test(e)) || emails[0];
|
|
191
|
+
const idx = txt.indexOf(email);
|
|
192
|
+
const near = txt.slice(Math.max(0, idx - 200), idx + 260);
|
|
193
|
+
const bad = s => s === email || /@/.test(s) || /\.(js|css|html|db|png|json|sqlite)$/i.test(s) || /^https?:/i.test(s) || /^[A-Z_]{6,}$/.test(s) || s.includes('/') || s.includes('\\');
|
|
194
|
+
const pool = [...near.matchAll(/['"`]([^'"`\s]{4,40})['"`]/g)].map(m => m[1]).filter(s => !bad(s));
|
|
195
|
+
// prefere algo com dígito (senhas costumam ter), depois curto
|
|
196
|
+
const senha = pool.find(s => /\d/.test(s)) || pool.find(s => s.length <= 24) || pool[0];
|
|
197
|
+
return (email && senha) ? { email, senha } : null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── GATE WEB (novo, pra portais/APIs Node): sobe o servidor DE VERDADE, checa se a porta
|
|
201
|
+
// responde (senão = erro de inicialização) e — com Chrome headless (puppeteer-core) — carrega
|
|
202
|
+
// a página, captura ERROS DE CONSOLE (ex: "Invalid Date") e tira PRINT pro olho criticar o
|
|
203
|
+
// visual. Se a home for uma TELA DE LOGIN, LOGA com o seed e verifica as telas AUTENTICADAS
|
|
204
|
+
// (senão dashboard/kanban quebrados passariam batido). Dá pra web a MESMA verificação real que apps têm.
|
|
205
|
+
async function webRunGate(b) {
|
|
206
|
+
const cwd = b.cwd, port = b.port || 3000, url = 'http://localhost:' + port + '/';
|
|
207
|
+
// instala deps se faltarem
|
|
208
|
+
try { if (!fs.existsSync(path.join(cwd, 'node_modules'))) { try { cp.execSync('npm install', { cwd, stdio: 'ignore', timeout: 240000 }); } catch (_) {} } } catch (_) {}
|
|
209
|
+
// sobe o servidor (shell:true resolve npm.cmd no Windows; detached pra matar a árvore)
|
|
210
|
+
let srv, srvlog = '';
|
|
211
|
+
try {
|
|
212
|
+
srv = cp.spawn(b.startCmd, { cwd, detached: process.platform !== 'win32', shell: true, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
213
|
+
srv.stdout && srv.stdout.on('data', d => { srvlog += d.toString(); });
|
|
214
|
+
srv.stderr && srv.stderr.on('data', d => { srvlog += d.toString(); });
|
|
215
|
+
} catch (e) { return { ok: false, crash: 'não consegui iniciar o servidor: ' + e.message }; }
|
|
216
|
+
const kill = () => { try { if (process.platform === 'win32') cp.execSync('taskkill /F /T /PID ' + srv.pid, { stdio: 'ignore' }); else { try { process.kill(-srv.pid); } catch (_) { process.kill(srv.pid); } } } catch (_) {} };
|
|
217
|
+
// checa a porta com o módulo http (sem curl — evita quoting de %{http_code} no cmd)
|
|
218
|
+
const http = require('http');
|
|
219
|
+
const ping = () => new Promise(res => { const rq = http.get({ host: '127.0.0.1', port, path: '/', timeout: 3000 }, r => { r.resume(); res(r.statusCode || 0); }); rq.on('error', () => res(0)); rq.on('timeout', () => { rq.destroy(); res(0); }); });
|
|
220
|
+
let up = false;
|
|
221
|
+
for (let i = 0; i < 20; i++) {
|
|
222
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
223
|
+
const code = await ping();
|
|
224
|
+
if (code && code < 500) { up = true; break; }
|
|
225
|
+
if (/error:|throw |cannot find module|EADDRINUSE|listen EACCES|SyntaxError|ReferenceError/i.test(srvlog)) break;
|
|
226
|
+
}
|
|
227
|
+
if (!up) { kill(); return { ok: false, crash: 'O servidor web NÃO subiu (a porta ' + port + ' não respondeu). Corrija a inicialização/dependências. Saída do servidor:\n' + srvlog.slice(-1600) }; }
|
|
228
|
+
// Chrome headless: carrega a página, captura console/pageerror + print + texto quebrado
|
|
229
|
+
const chrome = _findChrome();
|
|
230
|
+
let shotFile = null;
|
|
231
|
+
if (chrome) {
|
|
232
|
+
try {
|
|
233
|
+
const puppeteer = require('puppeteer-core');
|
|
234
|
+
const browser = await puppeteer.launch({ executablePath: chrome, headless: 'new', args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'] });
|
|
235
|
+
const page = await browser.newPage();
|
|
236
|
+
await page.setViewport({ width: 1280, height: 850 });
|
|
237
|
+
const errs = [];
|
|
238
|
+
const _benign = (u) => /favicon\.ico|\.ico(\?|$)|apple-touch-icon|robots\.txt|\/sw\.js|manifest\.json/i.test(String(u || ''));
|
|
239
|
+
// ERRO DE JS (pageerror) = sempre real. "Failed to load resource" genérico é ignorado
|
|
240
|
+
// (não traz URL) — quem julga recurso é o response listener, que sabe a URL.
|
|
241
|
+
page.on('pageerror', e => errs.push('erro de JS: ' + String(e && e.message).slice(0, 200)));
|
|
242
|
+
page.on('console', m => { const t = m.text(); if (m.type() === 'error' && !/Failed to load resource/i.test(t)) errs.push(t.slice(0, 200)); });
|
|
243
|
+
// 401/403 = desafio de auth NORMAL (a SPA chama rota protegida antes de logar) — não é bug.
|
|
244
|
+
// 500+ = erro de servidor REAL; 404 = rota/recurso faltando. Só esses reprovam.
|
|
245
|
+
page.on('response', r => { const s = r.status(); const u = r.url(); if ((s >= 500 || s === 404) && !_benign(u)) errs.push(`HTTP ${s} em ${u.replace(/^https?:\/\/[^/]+/, '').slice(0, 100)}`); });
|
|
246
|
+
await page.goto(url, { waitUntil: 'networkidle2', timeout: 20000 }).catch(() => {});
|
|
247
|
+
await new Promise(r => setTimeout(r, 1800));
|
|
248
|
+
|
|
249
|
+
// GATE AUTENTICADO: se a home tem campo de senha (=tela de login), LOGA com o seed e deixa
|
|
250
|
+
// a SPA carregar as telas internas (dashboard/kanban) — aí os checks abaixo pegam erro
|
|
251
|
+
// de backend (ex: dashboard 500) ou "Invalid Date" nas telas autenticadas, não só no login.
|
|
252
|
+
let authed = false, hasLogin = false, creds = null;
|
|
253
|
+
try {
|
|
254
|
+
// campo de senha VISÍVEL = tela de login (apps vanilla deixam o form escondido no DOM
|
|
255
|
+
// depois de logar, então "existe input" daria falso-positivo — usa offsetParent).
|
|
256
|
+
hasLogin = await page.evaluate(() => { const p = document.querySelector('input[type=password]'); return !!p && p.offsetParent !== null; });
|
|
257
|
+
creds = hasLogin ? _seedCreds(cwd) : null;
|
|
258
|
+
if (hasLogin && creds) {
|
|
259
|
+
const filled = await page.evaluate((email, senha) => {
|
|
260
|
+
const q = sels => { for (const s of sels) { const el = document.querySelector(s); if (el) return el; } return null; };
|
|
261
|
+
const ei = q(['input[type=email]', 'input[name*=email i]', 'input[id*=email i]', 'input[placeholder*=mail i]', 'input[type=text]']);
|
|
262
|
+
const pi = document.querySelector('input[type=password]');
|
|
263
|
+
if (!ei || !pi) return false;
|
|
264
|
+
const set = (el, v) => { const d = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value'); d && d.set ? d.set.call(el, v) : (el.value = v); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); };
|
|
265
|
+
set(ei, email); set(pi, senha);
|
|
266
|
+
const form = pi.closest('form');
|
|
267
|
+
const btn = (form && form.querySelector('button[type=submit], button')) || q(['button[type=submit]', 'button']);
|
|
268
|
+
if (btn) btn.click(); else if (form) (form.requestSubmit ? form.requestSubmit() : form.submit());
|
|
269
|
+
return true;
|
|
270
|
+
}, creds.email, creds.senha);
|
|
271
|
+
if (filled) {
|
|
272
|
+
await page.waitForNetworkIdle({ idleTime: 800, timeout: 8000 }).catch(() => {});
|
|
273
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
274
|
+
// logou = o campo de senha sumiu OU ficou invisível (a SPA trocou pra tela interna)
|
|
275
|
+
authed = await page.evaluate(() => { const p = document.querySelector('input[type=password]'); return !p || p.offsetParent === null; });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
} catch (_) {}
|
|
279
|
+
|
|
280
|
+
shotFile = path.join(_os.tmpdir(), 'ts-web-' + port + '.png');
|
|
281
|
+
try { await page.screenshot({ path: shotFile, fullPage: false }); } catch (_) { shotFile = null; }
|
|
282
|
+
let bodyText = ''; try { bodyText = await page.evaluate(() => (document.body ? document.body.innerText : '')); } catch (_) {}
|
|
283
|
+
const bad = [...new Set((bodyText.match(/Invalid Date|\bNaN\b|\bundefined\b|\[object Object\]|Cannot GET|Error:|TypeError|ReferenceError/g) || []))];
|
|
284
|
+
await browser.close().catch(() => {});
|
|
285
|
+
// Tela de login que o gate NÃO conseguiu atravessar = seed ausente / senha errada / login
|
|
286
|
+
// quebrado → as telas internas não têm como ser verificadas. É um problema REAL (o app
|
|
287
|
+
// precisa de um usuário de SEED funcional pra ser testável de ponta a ponta).
|
|
288
|
+
const authProblem = (hasLogin && !authed)
|
|
289
|
+
? 'Há uma TELA DE LOGIN mas o gate NÃO conseguiu ENTRAR' + (creds ? ' com o usuário de seed "' + creds.email + '"' : ' (nenhum usuário de SEED encontrado no código)') + '. Crie no seed um usuário demo FUNCIONAL (email+senha em texto no script) e garanta que o login aceita ele — senão dashboard/kanban e o resto ficam sem verificação.'
|
|
290
|
+
: '';
|
|
291
|
+
if (errs.length || bad.length || authProblem) {
|
|
292
|
+
kill();
|
|
293
|
+
return { ok: false, shot: shotFile, authed, crash: 'A verificação web falhou' + (authed ? ' (APÓS LOGIN, nas telas autenticadas)' : '') + ' (Chrome headless — corrija a CAUSA-RAIZ no código):\n'
|
|
294
|
+
+ (errs.length ? 'Console/erros: ' + errs.slice(0, 8).join(' | ') + '\n' : '')
|
|
295
|
+
+ (bad.length ? 'Texto quebrado VISÍVEL na tela: ' + bad.join(', ') + ' (ex: data não formatada, valor undefined/NaN)\n' : '')
|
|
296
|
+
+ (authProblem || '') };
|
|
297
|
+
}
|
|
298
|
+
kill();
|
|
299
|
+
return { ok: true, shot: shotFile, authed };
|
|
300
|
+
} catch (_) { /* puppeteer falhou → segue só com a verificação do servidor */ }
|
|
301
|
+
}
|
|
302
|
+
kill();
|
|
303
|
+
return { ok: true, shot: shotFile };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── ANÁLISE ESTÁTICA (economia, ideia do Codex): roda um checador RÁPIDO e BARATO
|
|
307
|
+
// ANTES do build pesado — pega erro de código de graça (flutter analyze ~8s vs build ~60s+;
|
|
308
|
+
// tsc/node --check pra JS/TS). Se achar erro, o executor corrige sem gastar o build inteiro.
|
|
309
|
+
function staticCheck(b) {
|
|
310
|
+
try {
|
|
311
|
+
if (b.kind === 'flutter') {
|
|
312
|
+
const fb = _findFlutter(); if (!fb) return null;
|
|
313
|
+
let out = ''; try { out = cp.execSync(`"${fb}" analyze --no-pub 2>&1`, { cwd: b.cwd, encoding: 'utf8', timeout: 120000, maxBuffer: 3e7 }); }
|
|
314
|
+
catch (e) { out = (e.stdout || '') + (e.stderr || ''); }
|
|
315
|
+
const errs = out.split('\n').filter(l => /^\s*error\b|^\s*error •/i.test(l)).slice(0, 30);
|
|
316
|
+
if (errs.length) return { ok: false, out: 'flutter analyze achou erros (corrija ANTES de compilar):\n' + errs.join('\n').slice(0, 2500) };
|
|
317
|
+
return { ok: true };
|
|
318
|
+
}
|
|
319
|
+
if (b.kind === 'node') {
|
|
320
|
+
// tsc --noEmit se for TS; senão node --check em cada .js do projeto (rápido)
|
|
321
|
+
const hasTs = fs.existsSync(path.join(b.cwd, 'tsconfig.json'));
|
|
322
|
+
if (hasTs) {
|
|
323
|
+
let out = ''; try { cp.execSync('npx -y tsc --noEmit 2>&1', { cwd: b.cwd, encoding: 'utf8', timeout: 120000, maxBuffer: 3e7 }); }
|
|
324
|
+
catch (e) { out = (e.stdout || '') + (e.stderr || ''); }
|
|
325
|
+
const errs = out.split('\n').filter(l => /error TS\d+/.test(l)).slice(0, 30);
|
|
326
|
+
if (errs.length) return { ok: false, out: 'tsc achou erros de tipo (corrija ANTES de rodar):\n' + errs.join('\n').slice(0, 2500) };
|
|
327
|
+
}
|
|
328
|
+
return { ok: true };
|
|
329
|
+
}
|
|
330
|
+
} catch (_) {}
|
|
331
|
+
return null; // sem checador barato pra esse kind (android puro: o build é o check)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ── FASE DE VIABILIDADE (ideia do Codex): antes de codar app AMBICIOSO, avalia o que é
|
|
335
|
+
// tecnicamente POSSÍVEL e escopa com honestidade — evita gastar rodadas tentando o impossível
|
|
336
|
+
// (ex: "abrir 5 WhatsApp nativos" no Android não dá; a versão real é WebView isolado).
|
|
337
|
+
async function feasibilityPhase(goal, model, token) {
|
|
338
|
+
const sys = 'Você é um ARQUITETO SÊNIOR pragmático. Dado o objetivo do app, faça uma AVALIAÇÃO DE VIABILIDADE curta e HONESTA:\n'
|
|
339
|
+
+ '1) O que é 100% possível com as APIs/plataforma normais.\n'
|
|
340
|
+
+ '2) O que NÃO é possível (limite técnico/da plataforma/legal) e POR QUÊ — seja específico.\n'
|
|
341
|
+
+ '3) A VERSÃO REALISTA de cada parte impossível (o que dá pra entregar de fato que resolve a intenção do usuário).\n'
|
|
342
|
+
+ '4) 1 recomendação de arquitetura pra viabilizar o núcleo.\n'
|
|
343
|
+
+ 'Direto, sem enrolação, máx ~350 palavras. Se TUDO for viável, diga isso em 1 linha.';
|
|
344
|
+
const r = await _llmText({ token, model, system: sys, user: `OBJETIVO:\n${String(goal).slice(0, 2000)}`, maxTokens: 900 });
|
|
345
|
+
return { brief: r.text, credits: r.credits };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ── SCAFFOLD (ideia do Codex): pra projeto NOVO de tipo conhecido, usa o gerador OFICIAL
|
|
349
|
+
// (flutter create / npm init) pra a base — código boilerplate correto de fábrica = menos
|
|
350
|
+
// erro de build. O modelo edita em cima, não escreve tudo do zero.
|
|
351
|
+
function scaffold(goal, dir, onAlert) {
|
|
352
|
+
try {
|
|
353
|
+
const g = String(goal).toLowerCase();
|
|
354
|
+
const empty = (() => { try { return fs.readdirSync(dir).filter(f => f !== '.ts-meta.json' && !f.startsWith('_') && !f.endsWith('.md') && !f.endsWith('.txt')).length === 0; } catch (_) { return true; } })();
|
|
355
|
+
if (!empty) return null; // já tem código — não scaffolda
|
|
356
|
+
if (/\bflutter\b/.test(g)) {
|
|
357
|
+
const fb = _findFlutter(); if (!fb) return null;
|
|
358
|
+
const name = (g.match(/chamado\s+([a-z][\w]*)/i) || [])[1] || 'app';
|
|
359
|
+
if (onAlert) onAlert({ type: 'setup', text: `🏗 ts: criando a base Flutter com o gerador oficial (flutter create) — base limpa, menos erro de build.` });
|
|
360
|
+
cp.execSync(`"${fb}" create --platforms=android --project-name ${name.toLowerCase()} "${dir}"`, { stdio: 'ignore', timeout: 180000 });
|
|
361
|
+
return { kind: 'flutter', name };
|
|
362
|
+
}
|
|
363
|
+
if (/\b(express|node\.?js|api rest|servidor node|portal web|site)\b/.test(g) && !/\bflutter\b|\bandroid\b|\bkotlin\b/.test(g)) {
|
|
364
|
+
if (onAlert) onAlert({ type: 'setup', text: `🏗 ts: iniciando projeto Node (npm init) — base limpa.` });
|
|
365
|
+
try { cp.execSync('npm init -y', { cwd: dir, stdio: 'ignore', timeout: 30000 }); } catch (_) {}
|
|
366
|
+
return { kind: 'node' };
|
|
367
|
+
}
|
|
368
|
+
} catch (_) {}
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ── RUN GATE: instala o APK num emulador/aparelho, ABRE o app de verdade e lê o
|
|
373
|
+
// logcat pra pegar CRASH DE RUNTIME (o build gate só pega erro de compilação; um
|
|
374
|
+
// app pode compilar e crashar ao abrir). Precisa de adb + 1 device online.
|
|
375
|
+
function _adb() { const p = path.join(process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT || path.join(require('os').homedir(), 'AppData/Local/Android/Sdk'), 'platform-tools', 'adb.exe'); return fs.existsSync(p) ? p : 'adb'; }
|
|
376
|
+
function _appId(projRoot) {
|
|
377
|
+
// acha o applicationId/namespace do app (pra instalar/abrir/filtrar o logcat).
|
|
378
|
+
// Android puro: app/build.gradle; Flutter: android/app/build.gradle.
|
|
379
|
+
for (const f of ['app/build.gradle.kts', 'app/build.gradle', 'android/app/build.gradle.kts', 'android/app/build.gradle']) {
|
|
380
|
+
try { const t = fs.readFileSync(path.join(projRoot, f), 'utf8'); const m = t.match(/applicationId\s*=?\s*["']([\w.]+)["']/) || t.match(/namespace\s*=?\s*["']([\w.]+)["']/); if (m) return m[1]; } catch (_) {}
|
|
381
|
+
}
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
// ── EMULADOR: o harness garante um device Android online (autonomia de ambiente).
|
|
385
|
+
// Se não houver device: liga o primeiro AVD existente; se NÃO existir nenhum AVD,
|
|
386
|
+
// baixa a system image (sdkmanager) e CRIA um (avdmanager) — o modelo nunca faz isso.
|
|
387
|
+
function _sdkRoot() { return process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT || path.join(require('os').homedir(), 'AppData/Local/Android/Sdk'); }
|
|
388
|
+
function _deviceOnline() {
|
|
389
|
+
try { const d = cp.execSync(`"${_adb()}" devices`, { encoding: 'utf8', timeout: 15000 }); return d.split('\n').slice(1).some(l => /\tdevice\s*$/.test(l)); } catch (_) { return false; }
|
|
390
|
+
}
|
|
391
|
+
function ensureEmulator(onAlert) {
|
|
392
|
+
if (_deviceOnline()) return { ok: true, already: true };
|
|
393
|
+
const sdk = _sdkRoot();
|
|
394
|
+
const emu = path.join(sdk, 'emulator', 'emulator.exe');
|
|
395
|
+
if (!fs.existsSync(emu)) return { ok: false, reason: 'emulator não instalado no Android SDK' };
|
|
396
|
+
let avds = [];
|
|
397
|
+
try { avds = cp.execSync(`"${emu}" -list-avds`, { encoding: 'utf8', timeout: 30000 }).split(/\r?\n/).map(s => s.trim()).filter(s => s && !/^INFO/.test(s)); } catch (_) {}
|
|
398
|
+
if (!avds.length) {
|
|
399
|
+
// nenhum AVD → cria um do zero (baixa a system image; pode levar vários minutos)
|
|
400
|
+
const sdkm = path.join(sdk, 'cmdline-tools', 'latest', 'bin', 'sdkmanager.bat');
|
|
401
|
+
const avdm = path.join(sdk, 'cmdline-tools', 'latest', 'bin', 'avdmanager.bat');
|
|
402
|
+
if (!fs.existsSync(sdkm) || !fs.existsSync(avdm)) return { ok: false, reason: 'cmdline-tools ausentes — não consigo criar AVD' };
|
|
403
|
+
try {
|
|
404
|
+
if (onAlert) onAlert({ type: 'setup', text: '📥 ts: nenhum emulador (AVD) — baixando system image e criando um (pode levar vários minutos)…' });
|
|
405
|
+
cp.execSync(`echo y| "${sdkm}" "system-images;android-34;google_apis;x86_64"`, { stdio: 'ignore', timeout: 1500000 });
|
|
406
|
+
cp.execSync(`echo no| "${avdm}" create avd -n ts_emulador -k "system-images;android-34;google_apis;x86_64" -d pixel_6`, { stdio: 'ignore', timeout: 120000 });
|
|
407
|
+
avds = ['ts_emulador'];
|
|
408
|
+
} catch (e) { return { ok: false, reason: 'falha ao criar AVD: ' + String(e.message).slice(0, 80) }; }
|
|
409
|
+
}
|
|
410
|
+
if (onAlert) onAlert({ type: 'setup', text: `▶ ts: ligando o emulador ${avds[0]} pra testar o app de verdade…` });
|
|
411
|
+
try { cp.spawn(emu, ['-avd', avds[0], '-no-snapshot-save', '-no-boot-anim'], { detached: true, stdio: 'ignore' }).unref(); } catch (e) { return { ok: false, reason: 'falha ao iniciar o emulador: ' + String(e.message).slice(0, 80) }; }
|
|
412
|
+
// espera o boot completar (até ~4 min)
|
|
413
|
+
for (let i = 0; i < 48; i++) {
|
|
414
|
+
_wait(5);
|
|
415
|
+
try { if (cp.execSync(`"${_adb()}" shell getprop sys.boot_completed`, { encoding: 'utf8', timeout: 10000 }).trim() === '1') { _wait(5); return { ok: true, booted: true }; } } catch (_) {}
|
|
416
|
+
}
|
|
417
|
+
return { ok: false, reason: 'o emulador não terminou o boot a tempo' };
|
|
418
|
+
}
|
|
419
|
+
function runApp(b, projRoot, onAlert) {
|
|
420
|
+
const adb = _adb();
|
|
421
|
+
if (!_deviceOnline()) {
|
|
422
|
+
const em = ensureEmulator(onAlert); // liga (ou cria) o emulador sozinho
|
|
423
|
+
if (!em.ok) return { skipped: true, reason: 'sem emulador/aparelho online (' + em.reason + ')' };
|
|
424
|
+
}
|
|
425
|
+
const apk = path.join(b.cwd, b.artifact);
|
|
426
|
+
const pkg = _appId(projRoot) || _appId(b.cwd);
|
|
427
|
+
if (!fs.existsSync(apk) || !pkg) return { skipped: true, reason: 'apk ou packageId não encontrado' };
|
|
428
|
+
try {
|
|
429
|
+
cp.execSync(`"${adb}" install -r -g "${apk}"`, { encoding: 'utf8', timeout: 120000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
430
|
+
cp.execSync(`"${adb}" logcat -c`, { timeout: 15000 });
|
|
431
|
+
cp.execSync(`"${adb}" shell monkey -p ${pkg} -c android.intent.category.LAUNCHER 1`, { timeout: 30000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
432
|
+
cp.execSync('ping -n 6 127.0.0.1 > nul', { shell: true, timeout: 10000 }); // ~5s pra o app subir/crashar
|
|
433
|
+
const log = cp.execSync(`"${adb}" logcat -d -v brief`, { encoding: 'utf8', timeout: 20000, maxBuffer: 3e7 });
|
|
434
|
+
// procura o crash (FATAL EXCEPTION / AndroidRuntime) referente ao nosso pacote.
|
|
435
|
+
// TAMBÉM pega exceção do FLUTTER (a "tela vermelha"): o processo NÃO morre, mas o
|
|
436
|
+
// logcat mostra "Unhandled Exception"/"EXCEPTION CAUGHT BY" — bug real de produção
|
|
437
|
+
// (HiveError virava tela vermelha/preta e o gate antigo aprovava porque o pid vivia).
|
|
438
|
+
const lines = log.split('\n');
|
|
439
|
+
const fatalIdx = lines.findIndex(l => /FATAL EXCEPTION|AndroidRuntime.*(FATAL|Exception)|E AndroidRuntime|Unhandled Exception|EXCEPTION CAUGHT BY/i.test(l));
|
|
440
|
+
if (fatalIdx >= 0) {
|
|
441
|
+
const crash = lines.slice(fatalIdx, fatalIdx + 30).filter(l => /AndroidRuntime|Exception|Error|at [\w.$#]+|Caused by|E \/|flutter/i.test(l)).slice(0, 24).join('\n');
|
|
442
|
+
return { ok: false, crash: ('ERRO DE RUNTIME (o app pode ter mostrado TELA VERMELHA/PRETA — exceção não tratada):\n' + crash).slice(0, 3000) };
|
|
443
|
+
}
|
|
444
|
+
// sem FATAL: confere se o processo do app está de pé (não morreu)
|
|
445
|
+
let ps = ''; try { ps = cp.execSync(`"${adb}" shell pidof ${pkg}`, { encoding: 'utf8', timeout: 15000 }); } catch (_) {}
|
|
446
|
+
if (ps.trim()) return { ok: true };
|
|
447
|
+
return { ok: false, crash: 'O app abriu e FECHOU sozinho (processo não está mais rodando), mas sem FATAL EXCEPTION no logcat. Verifique inicialização (onCreate, Room, tema, permissões) e binding de views.' };
|
|
448
|
+
} catch (e) { return { skipped: true, reason: 'falha ao instalar/abrir: ' + String(e.message).slice(0, 100) }; }
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ── VISUAL GATE: tira um PRINT do app rodando e um modelo com VISÃO (o "olho",
|
|
452
|
+
// default grok-4.5) critica o layout (nav torta, gráfico cortado, sobreposição).
|
|
453
|
+
// A crítica volta pro executor melhorar o visual. É o gate de APARÊNCIA.
|
|
454
|
+
async function _llmVision({ token, model, text, imageB64, images, maxTokens = 700 }) {
|
|
455
|
+
if (!_key) _key = await api('/api/ai/key?feature=cli_agent', { token, timeoutMs: 20000 });
|
|
456
|
+
// aceita 1 imagem (imageB64) ou VÁRIAS (images[]) na MESMA chamada de visão — 1 crítica cobre N telas
|
|
457
|
+
const imgs = (images && images.length ? images : [imageB64]).filter(Boolean);
|
|
458
|
+
const content = [{ type: 'text', text }].concat(imgs.map(b64 => ({ type: 'image_url', image_url: { url: 'data:image/png;base64,' + b64 } })));
|
|
459
|
+
let res; try {
|
|
460
|
+
res = await fetch(String(_key.baseUrl).replace(/\/+$/, '') + '/chat/completions', {
|
|
461
|
+
method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + _key.key },
|
|
462
|
+
// VISÃO: NÃO envia limite de tokens NENHUM. Com imagem, o z.ai (glm) TRUNCA em ~80
|
|
463
|
+
// tokens se vier max_tokens OU max_completion_tokens; sem limite devolve a crítica
|
|
464
|
+
// inteira (508 tok). O grok é naturalmente lacônico em visão de qualquer jeito.
|
|
465
|
+
// (a crítica do olho é curta por natureza — não precisa de teto.)
|
|
466
|
+
body: JSON.stringify({ model, messages: [{ role: 'user', content }] }),
|
|
467
|
+
});
|
|
468
|
+
} catch (_) { return { text: '', credits: 0 }; }
|
|
469
|
+
const j = await res.json().catch(() => null);
|
|
470
|
+
if (!res.ok) _capGuard(res, j);
|
|
471
|
+
const u = j?.usage || {};
|
|
472
|
+
let credits = 0;
|
|
473
|
+
if (u.prompt_tokens) { try { const c = await api('/api/credit/charge', { method: 'POST', token, body: { model, inTok: u.prompt_tokens || 0, outTok: u.completion_tokens || 0 } }); credits = (c && c.charged) || 0; } catch (_) {} }
|
|
474
|
+
return { text: String(j?.choices?.[0]?.message?.content || '').trim(), credits };
|
|
475
|
+
}
|
|
476
|
+
const _os = require('os');
|
|
477
|
+
function _screenshot(saveTo) {
|
|
478
|
+
try {
|
|
479
|
+
const adb = _adb();
|
|
480
|
+
const png = cp.execSync(`"${adb}" exec-out screencap -p`, { encoding: 'buffer', maxBuffer: 3e7, timeout: 20000 });
|
|
481
|
+
if (png && png.length > 5000) { if (saveTo) { try { fs.writeFileSync(saveTo, png); } catch (_) {} } return png.toString('base64'); }
|
|
482
|
+
} catch (_) {}
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
// Heurística barata: a tela ainda está CARREGANDO/quase vazia? (PNG pequeno = pouca
|
|
486
|
+
// variação de cor). Se sim, o print não vale a crítica — espera mais.
|
|
487
|
+
function _looksLoading(b64) { return !b64 || b64.length < 60000; }
|
|
488
|
+
// Print ESTÁVEL: pra app de REDE (feed que carrega por HTTP), o conteúdo aparece com
|
|
489
|
+
// atraso — se o print sai cedo, o olho vê "cards vazios" (falso-positivo). Tira prints
|
|
490
|
+
// em sequência e só retorna quando 2 seguidos ficam estáveis (tamanho do PNG ~igual) e
|
|
491
|
+
// não parecem loading. Espera até ~19s. Assim o olho critica a tela JÁ carregada.
|
|
492
|
+
function _stableShot(saveTo) {
|
|
493
|
+
let prev = _screenshot(saveTo);
|
|
494
|
+
for (let i = 0; i < 5; i++) {
|
|
495
|
+
_wait(3);
|
|
496
|
+
const cur = _screenshot(saveTo);
|
|
497
|
+
if (!cur) return prev;
|
|
498
|
+
if (prev && !_looksLoading(cur) && Math.abs(cur.length - prev.length) / Math.max(cur.length, 1) < 0.04) return cur;
|
|
499
|
+
prev = cur;
|
|
500
|
+
}
|
|
501
|
+
return prev;
|
|
502
|
+
}
|
|
503
|
+
function _wait(secs) { try { cp.execSync(`ping -n ${Math.max(2, Math.round(secs) + 1)} 127.0.0.1 > nul`, { shell: true, timeout: (secs + 3) * 1000 }); } catch (_) {} }
|
|
504
|
+
function _screenSize() {
|
|
505
|
+
try { const o = cp.execSync(`"${_adb()}" shell wm size`, { encoding: 'utf8', timeout: 15000 }); const m = o.match(/Override size:\s*(\d+)x(\d+)/) || o.match(/Physical size:\s*(\d+)x(\d+)/); if (m) return { w: +m[1], h: +m[2] }; } catch (_) {}
|
|
506
|
+
return { w: 1080, h: 2400 };
|
|
507
|
+
}
|
|
508
|
+
function _tap(x, y) { try { cp.execSync(`"${_adb()}" shell input tap ${Math.round(x)} ${Math.round(y)}`, { timeout: 15000, stdio: 'ignore' }); } catch (_) {} }
|
|
509
|
+
function _back() { try { cp.execSync(`"${_adb()}" shell input keyevent 4`, { timeout: 15000, stdio: 'ignore' }); } catch (_) {} }
|
|
510
|
+
// Explora o app pra ver telas ALÉM da inicial: abre o diálogo/tela de "adicionar" (FAB).
|
|
511
|
+
// O FAB pode estar em QUALQUER canto inferior (o executor pode movê-lo) — tenta direita E
|
|
512
|
+
// esquerda e fica com a tela que MAIS difere da Home (dialog muda muito o tamanho do PNG).
|
|
513
|
+
// Comparar base64 por igualdade exata NÃO serve (relógio/antialias mudam sempre) → usa a
|
|
514
|
+
// diferença relativa de tamanho do PNG como sinal barato de "tela nova".
|
|
515
|
+
function _explore(pkg, homeB64) {
|
|
516
|
+
const shots = [];
|
|
517
|
+
let navDead = false, blackAfterBack = false;
|
|
518
|
+
const homeLen = homeB64.length;
|
|
519
|
+
const _alive = () => { try { return !!cp.execSync(`"${_adb()}" shell pidof ${pkg}`, { encoding: 'utf8', timeout: 10000 }).trim(); } catch (_) { return false; } };
|
|
520
|
+
const _launch = () => { try { cp.execSync(`"${_adb()}" shell monkey -p ${pkg} -c android.intent.category.LAUNCHER 1`, { stdio: 'ignore', timeout: 15000 }); } catch (_) {} };
|
|
521
|
+
try {
|
|
522
|
+
const { w, h } = _screenSize();
|
|
523
|
+
const cands = [[w * 0.89, h * 0.865], [w * 0.11, h * 0.865]]; // canto inf-direito e inf-esquerdo
|
|
524
|
+
let best = null, bestDiff = 0.05; // exige >5% de diferença de tamanho pra contar como tela realmente nova
|
|
525
|
+
for (const [x, y] of cands) {
|
|
526
|
+
_tap(x, y); _wait(2);
|
|
527
|
+
const shot = _screenshot(null);
|
|
528
|
+
_back(); _wait(1); // fecha (dialog/tela) pra não deixar o app sujo antes do próximo candidato
|
|
529
|
+
if (shot && !_looksLoading(shot)) {
|
|
530
|
+
const diff = Math.abs(shot.length - homeLen) / homeLen;
|
|
531
|
+
if (diff > bestDiff) { best = shot; bestDiff = diff; }
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if (best) shots.push({ label: 'tela de adicionar/diálogo (FAB)', b64: best });
|
|
535
|
+
// ── TESTE DO BOTÃO VOLTAR (bug real de produção: voltar SAÍA do app / tela preta) ──
|
|
536
|
+
// Troca de aba (toque no meio da bottom nav); se a tela MUDOU, aperta VOLTAR:
|
|
537
|
+
// o app deve continuar vivo e sem tela preta. Só testa se o toque mudou a tela
|
|
538
|
+
// (senão o app nem tem nav ali e VOLTAR na raiz sai legitimamente).
|
|
539
|
+
if (!_alive()) _launch(); _wait(2); // garante o app de pé (os probes acima podem tê-lo fechado)
|
|
540
|
+
const before = _screenshot(null);
|
|
541
|
+
_tap(w * 0.5, h * 0.955); _wait(1.5);
|
|
542
|
+
const after1 = _screenshot(null);
|
|
543
|
+
const changed = before && after1 && Math.abs(after1.length - before.length) / before.length > 0.02;
|
|
544
|
+
if (changed) {
|
|
545
|
+
_back(); _wait(1.5);
|
|
546
|
+
if (!_alive()) navDead = true;
|
|
547
|
+
else { const shot2 = _screenshot(null); if (shot2 && _looksLoading(shot2)) { blackAfterBack = true; shots.push({ label: 'tela após apertar VOLTAR (possível tela preta/vazia)', b64: shot2 }); } }
|
|
548
|
+
}
|
|
549
|
+
} catch (_) {}
|
|
550
|
+
return { shots, navDead, blackAfterBack };
|
|
551
|
+
}
|
|
552
|
+
async function visualGate(b, projRoot, eye, token, designBrief, mockupB64) {
|
|
553
|
+
const pkg = _appId(projRoot) || _appId(b.cwd);
|
|
554
|
+
try { cp.execSync(`"${_adb()}" shell monkey -p ${pkg} -c android.intent.category.LAUNCHER 1`, { stdio: 'ignore', timeout: 20000 }); } catch (_) {}
|
|
555
|
+
// (a) ESPERA a tela assentar (inclui carregamento de REDE) — só critica o print estável
|
|
556
|
+
_wait(3);
|
|
557
|
+
const shotFile = path.join(_os.tmpdir(), 'ts-visual-' + pkg + '.png');
|
|
558
|
+
let home = _stableShot(shotFile);
|
|
559
|
+
if (!home) return { skipped: true, reason: 'não consegui o print' };
|
|
560
|
+
// (b) EXPLORA: além da Home, abre o diálogo/tela do FAB (onde ficam seletores de ícone/cor
|
|
561
|
+
// que a Home não mostra) e testa o BOTÃO VOLTAR (bug real: voltar saía do app / tela preta).
|
|
562
|
+
try { cp.execSync(`"${_adb()}" logcat -c`, { timeout: 15000 }); } catch (_) {} // zera o log pra atribuir exceções às interações abaixo
|
|
563
|
+
const ex = _explore(pkg, home);
|
|
564
|
+
// TELA VERMELHA nas interações (exceção Flutter não tratada) → reprovação DETERMINÍSTICA (grátis)
|
|
565
|
+
try {
|
|
566
|
+
const lg = cp.execSync(`"${_adb()}" logcat -d -v brief`, { encoding: 'utf8', timeout: 20000, maxBuffer: 3e7 });
|
|
567
|
+
const li = lg.split('\n'); const xi = li.findIndex(l => /Unhandled Exception|EXCEPTION CAUGHT BY|HiveError/i.test(l));
|
|
568
|
+
if (xi >= 0) {
|
|
569
|
+
const exc = li.slice(xi, xi + 20).filter(l => /Exception|Error|flutter|at [\w.$#]+/i.test(l)).slice(0, 14).join('\n');
|
|
570
|
+
return { ok: false, severity: 'estrutural', credits: 0, shot: shotFile, screens: 1,
|
|
571
|
+
critique: 'ERRO FUNCIONAL REAL (detectado ao INTERAGIR com o app — tela vermelha/exceção não tratada): ao tocar em elementos da interface o Flutter lançou exceção. Corrija a CAUSA-RAIZ no código (não é estética):\n' + exc };
|
|
572
|
+
}
|
|
573
|
+
} catch (_) {}
|
|
574
|
+
// VOLTAR matou o app → reprovação DETERMINÍSTICA (grátis — nem gasta o olho)
|
|
575
|
+
if (ex.navDead) {
|
|
576
|
+
return { ok: false, severity: 'estrutural', credits: 0, shot: shotFile, screens: 1,
|
|
577
|
+
critique: 'BUG ESTRUTURAL DE NAVEGAÇÃO (detectado por teste real, não por opinião): ao trocar de aba e apertar o botão VOLTAR do sistema, o app FECHOU (processo morreu). O botão voltar NUNCA pode sair do app a partir de uma tela/aba interna — deve voltar pra aba/tela anterior. Fix: use rotas aninhadas (go_router StatefulShellRoute/ShellRoute) com PopScope tratando o back do sistema: se não estiver na aba inicial, volte pra ela; só saia do app se já estiver na raiz. Garanta também que a seta de voltar da AppBar usa pop() da MESMA pilha (nunca push de nova tela nem tela preta).' };
|
|
578
|
+
}
|
|
579
|
+
const shots = [{ label: 'tela inicial (Home)', b64: home }].concat(ex.shots);
|
|
580
|
+
// re-abre a Home pra deixar o app num estado limpo pro próximo ciclo
|
|
581
|
+
try { cp.execSync(`"${_adb()}" shell monkey -p ${pkg} -c android.intent.category.LAUNCHER 1`, { stdio: 'ignore', timeout: 15000 }); } catch (_) {}
|
|
582
|
+
// (c) OLHO rígido, mas com SEVERIDADE: só reprova em defeito ESTRUTURAL. Nitpick cosmético
|
|
583
|
+
// NÃO reprova (economia: o loop de polimento com modelo forte é caro; não vale queimar rodada
|
|
584
|
+
// com implicância que não quebra usabilidade nem o layout).
|
|
585
|
+
const mockPrefix = mockupB64
|
|
586
|
+
? 'A PRIMEIRA imagem é um MOCKUP DE REFERÊNCIA (o ALVO desenhado — NÃO é o app). As telas seguintes são o APP REAL. Além dos checkpoints, COMPARE o app com o mockup: divergência GRANDE de estrutura/paleta/componentes em relação ao mockup é ESTRUTURAL; diferença sutil de tom/espacamento é COSMÉTICO. '
|
|
587
|
+
: '';
|
|
588
|
+
const prompt = 'Você é um DIRETOR DE ARTE revisando PRINTS REAIS de um app Android rodando. ' + mockPrefix
|
|
589
|
+
+ 'Vou te enviar ' + shots.length + ' tela(s) do app, NESTA ordem: ' + shots.map((s, i) => `(${i + 1}) ${s.label}`).join('; ') + '. '
|
|
590
|
+
+ 'Analise cada tela e classifique os problemas em DUAS categorias:\n'
|
|
591
|
+
+ 'ESTRUTURAL (grave — quebra o layout ou a usabilidade): elemento CORTADO/incompleto (card/lista/gráfico/anel cortado pela borda ou por outro componente), item de lista horizontal cortado nas pontas (falta scroll+padding), SOBREPOSIÇÃO (texto sobre ícone, componente cobrindo outro), texto TRUNCADO importante, bottom nav com indicador cobrindo só metade/desalinhado ou ícone/label faltando, conteúdo ILEGÍVEL, tela quebrada/vazia/preta sem motivo, e SAFE AREA: qualquer elemento INTERATIVO (FAB, botão, item de nav) COLADO na borda inferior ou superior da tela (a barra de gestos/navegação do sistema sobrepõe — precisa de SafeArea/margem ≥16-24dp).\n'
|
|
592
|
+
+ 'COSMÉTICO (nitpick — não quebra nada): concordância de plural ("1 hábitos"), contraste levemente fraco mas legível, micro-espaçamento, sombra/opacidade sutil, preferência de cor/estilo. NÃO trate cosmético como grave.\n'
|
|
593
|
+
+ 'CHECKPOINTS a verificar: bottom nav (indicador equilibrado e consistente, ícone acima do label); listas/carrosséis horizontais (1º e último item INTEIROS, com scroll por toque); gráficos/anéis completos; nada cortado/sobreposto; conteúdo carregado (não confunda skeleton/shimmer de loading com tela vazia).\n'
|
|
594
|
+
+ 'Liste os problemas ESTRUTURAIS (se houver) com QUAL tela, ONDE e o FIX TÉCNICO exato (widget/atributo). Depois liste os COSMÉTICOS à parte.\n'
|
|
595
|
+
+ 'TERMINE a resposta com UMA linha, exatamente um destes veredictos:\n'
|
|
596
|
+
+ '"VEREDITO: OK" — se estiver impecável (nenhum problema).\n'
|
|
597
|
+
+ '"VEREDITO: RESSALVAS" — se só houver problemas COSMÉTICOS (nenhum estrutural). Isto APROVA o app.\n'
|
|
598
|
+
+ '"VEREDITO: REPROVADO" — se houver QUALQUER problema ESTRUTURAL.'
|
|
599
|
+
+ (designBrief ? '\n\nO app DEVE seguir este design system:\n' + String(designBrief).slice(0, 1400) : '');
|
|
600
|
+
const imgs = (mockupB64 ? [mockupB64] : []).concat(shots.map(s => s.b64));
|
|
601
|
+
const r = await _llmVision({ token, model: eye, text: prompt, images: imgs, maxTokens: 1100 });
|
|
602
|
+
const txt = String(r.text || '');
|
|
603
|
+
// veredito explícito manda; fallback pro comportamento antigo se o olho não emitir a linha.
|
|
604
|
+
let severity, ok;
|
|
605
|
+
if (/VEREDITO:\s*REPROVAD/i.test(txt)) { severity = 'estrutural'; ok = false; }
|
|
606
|
+
else if (/VEREDITO:\s*RESSALV/i.test(txt)) { severity = 'ressalvas'; ok = true; }
|
|
607
|
+
else if (/VEREDITO:\s*OK/i.test(txt)) { severity = 'ok'; ok = true; }
|
|
608
|
+
else { ok = /^\s*VISUAL OK\s*$/i.test(txt.trim()) || txt.length < 12; severity = ok ? 'ok' : 'estrutural'; }
|
|
609
|
+
return { ok, severity, critique: txt, credits: r.credits, shot: shotFile, screens: shots.length };
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function stateFile(dir) { return path.join(dir || process.cwd(), FILE); }
|
|
613
|
+
|
|
614
|
+
// Mapa compacto dos arquivos do projeto (grátis) — injetado no contexto de cada
|
|
615
|
+
// rodada pra o agente NÃO precisar relistar/rebuscar o projeto toda vez (era o
|
|
616
|
+
// maior vilão de custo: ~5-6 tool calls de exploração por rodada). Ignora ruído.
|
|
617
|
+
const _SKIP = new Set(['.git', 'node_modules', 'build', '.gradle', '.idea', 'dist', '.ts-meta.json']);
|
|
618
|
+
function projectMap(dir, max = 120) {
|
|
619
|
+
const out = [];
|
|
620
|
+
const walk = (d, rel, depth) => {
|
|
621
|
+
if (depth > 6 || out.length >= max) return;
|
|
622
|
+
let list; try { list = fs.readdirSync(d, { withFileTypes: true }); } catch (_) { return; }
|
|
623
|
+
for (const e of list) {
|
|
624
|
+
if (out.length >= max) return;
|
|
625
|
+
if (_SKIP.has(e.name) || e.name.startsWith('.')) continue;
|
|
626
|
+
const r = rel ? rel + '/' + e.name : e.name;
|
|
627
|
+
if (e.isDirectory()) walk(path.join(d, e.name), r, depth + 1);
|
|
628
|
+
else out.push(r);
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
try { walk(dir, '', 0); } catch (_) {}
|
|
632
|
+
return out;
|
|
633
|
+
}
|
|
634
|
+
function load(dir) {
|
|
635
|
+
try { return JSON.parse(fs.readFileSync(stateFile(dir), 'utf8')); } catch (_) { return null; }
|
|
636
|
+
}
|
|
637
|
+
// Escrita ATÔMICA (tmp + rename): uma queda no meio do write não corrompe o
|
|
638
|
+
// .ts-meta.json — sem isso, load() devolveria null e a missão recomeçaria do zero.
|
|
639
|
+
function save(st, dir) {
|
|
640
|
+
const f = stateFile(dir), tmp = f + '.tmp';
|
|
641
|
+
try {
|
|
642
|
+
fs.writeFileSync(tmp, JSON.stringify(st, null, 2));
|
|
643
|
+
fs.renameSync(tmp, f);
|
|
644
|
+
} catch (_) {
|
|
645
|
+
try { fs.writeFileSync(f, JSON.stringify(st, null, 2)); } catch (_) {} // fallback direto
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Chamada JSON barata (flash-lite via CDC, chave sk-hub do usuário) + cobrança
|
|
650
|
+
let _key = null;
|
|
651
|
+
async function _llmJson({ token, system, user, maxTokens = 900 }) {
|
|
652
|
+
if (!_key) _key = await api('/api/ai/key?feature=cli_agent', { token, timeoutMs: 20000 });
|
|
653
|
+
const ctrl = new AbortController();
|
|
654
|
+
const timer = setTimeout(() => ctrl.abort(), 60000);
|
|
655
|
+
let res;
|
|
656
|
+
try {
|
|
657
|
+
res = await fetch(String(_key.baseUrl).replace(/\/+$/, '') + '/chat/completions', {
|
|
658
|
+
method: 'POST', signal: ctrl.signal,
|
|
659
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + _key.key },
|
|
660
|
+
body: JSON.stringify({ model: 'gemini-2.5-flash-lite', stream: false, max_completion_tokens: maxTokens,
|
|
661
|
+
messages: [{ role: 'system', content: system }, { role: 'user', content: user }] }),
|
|
662
|
+
});
|
|
663
|
+
} finally { clearTimeout(timer); }
|
|
664
|
+
const j = await res.json().catch(() => null);
|
|
665
|
+
if (!res.ok) _capGuard(res, j);
|
|
666
|
+
const u = j?.usage || {};
|
|
667
|
+
let credits = 0;
|
|
668
|
+
if (u.prompt_tokens) {
|
|
669
|
+
try { const c = await api('/api/credit/charge', { method: 'POST', token, body: { model: 'gemini-2.5-flash-lite', inTok: u.prompt_tokens || 0, outTok: u.completion_tokens || 0 } }); credits = (c && c.charged) || 0; } catch (_) {}
|
|
670
|
+
}
|
|
671
|
+
return { json: _tolerantJson(String(j?.choices?.[0]?.message?.content || '')), credits };
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Chamada de TEXTO com modelo arbitrário — usada pelo PENSADOR (escalonamento).
|
|
675
|
+
async function _llmText({ token, model, system, user, maxTokens = 1200 }) {
|
|
676
|
+
if (!_key) _key = await api('/api/ai/key?feature=cli_agent', { token, timeoutMs: 20000 });
|
|
677
|
+
const ctrl = new AbortController();
|
|
678
|
+
const timer = setTimeout(() => ctrl.abort(), 120000);
|
|
679
|
+
let res;
|
|
680
|
+
try {
|
|
681
|
+
res = await fetch(String(_key.baseUrl).replace(/\/+$/, '') + '/chat/completions', {
|
|
682
|
+
method: 'POST', signal: ctrl.signal,
|
|
683
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + _key.key },
|
|
684
|
+
body: JSON.stringify({ model, stream: false, max_completion_tokens: maxTokens, messages: [{ role: 'system', content: system }, { role: 'user', content: user }] }),
|
|
685
|
+
});
|
|
686
|
+
} finally { clearTimeout(timer); }
|
|
687
|
+
const j = await res.json().catch(() => null);
|
|
688
|
+
if (!res.ok) _capGuard(res, j);
|
|
689
|
+
const u = j?.usage || {};
|
|
690
|
+
let credits = 0;
|
|
691
|
+
if (u.prompt_tokens) { try { const c = await api('/api/credit/charge', { method: 'POST', token, body: { model, inTok: u.prompt_tokens || 0, outTok: u.completion_tokens || 0 } }); credits = (c && c.charged) || 0; } catch (_) {} }
|
|
692
|
+
return { text: String(j?.choices?.[0]?.message?.content || '').trim(), credits };
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// ESCALONAMENTO: o executor barato travou no mesmo erro. Chama o modelo CARO
|
|
696
|
+
// "pensador" numa análise FOCADA (sem ferramentas, sem loop) com o erro + o código
|
|
697
|
+
// dos arquivos citados, e devolve um diagnóstico + fix EXATO pro executor aplicar.
|
|
698
|
+
async function escalate(buildErr, projDir, thinker, token) {
|
|
699
|
+
const files = [...new Set([...String(buildErr).matchAll(/([A-Za-z]:[\\/][^\s:*?"<>|]+\.(?:kt|kts|xml|gradle|java|properties))/g)].map(m => m[1].replace(/\//g, path.sep)))].slice(0, 4);
|
|
700
|
+
let ctx = '';
|
|
701
|
+
for (const f of files) { try { const t = fs.readFileSync(f, 'utf8'); ctx += `\n----- ${f.split(path.sep).slice(-2).join('/')} -----\n` + t.split('\n').slice(0, 220).join('\n') + '\n'; } catch (_) {} }
|
|
702
|
+
const sys = 'Você é um engenheiro sênior de DEBUG. Recebe o ERRO REAL de um build e o código dos arquivos citados. '
|
|
703
|
+
+ 'Diga a CAUSA-RAIZ em 1-2 linhas e depois o FIX EXATO e mínimo: em QUAL arquivo, QUAL linha/trecho, e o QUE trocar (mostre o antes→depois curto). '
|
|
704
|
+
+ 'NÃO reescreva o app inteiro, só o necessário pra compilar. Seja preciso e direto.';
|
|
705
|
+
const r = await _llmText({ token, model: thinker, system: sys, user: `ERRO DO BUILD:\n${String(buildErr).slice(0, 2500)}\n\nCÓDIGO RELEVANTE:${ctx.slice(0, 6000)}`, maxTokens: 1200 });
|
|
706
|
+
return { hint: r.text, credits: r.credits };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// O objetivo parece um app/UI visual? (aí vale rodar a FASE DE DESIGN antes de codar)
|
|
710
|
+
function looksVisual(goal) {
|
|
711
|
+
return /\b(app|aplicativo|tela|telas|screen|ui|interface|site|p[áa]gina|dashboard|bonito|visual|design|android|flutter|ios|mobile|react|frontend|landing|layout)\b/i.test(String(goal || ''));
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// REGRAS DE VIDA EVOLUTIVAS: lições aprendidas com feedback real do usuário, num arquivo
|
|
715
|
+
// que cresce sem mudar código — ~/.ts/regras.md (global, todas as missões) + .ts-regras.md
|
|
716
|
+
// (por projeto). Injetadas na fase de design E em toda rodada do executor.
|
|
717
|
+
function loadRules(dir) {
|
|
718
|
+
let out = '';
|
|
719
|
+
for (const f of [path.join(require('os').homedir(), '.ts', 'regras.md'), path.join(dir || process.cwd(), '.ts-regras.md')]) {
|
|
720
|
+
try { const t = fs.readFileSync(f, 'utf8').trim(); if (t) out += (out ? '\n' : '') + t; } catch (_) {}
|
|
721
|
+
}
|
|
722
|
+
return out.slice(0, 3000);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// FASE DE ARQUITETURA (3º pilar): pra app COMPLEXO, o ARQUITETO (pensador forte) escreve
|
|
726
|
+
// um CONTRATO antes de codar — componentes, arquivos com caminho exato e, principalmente,
|
|
727
|
+
// NOMES/ASSINATURAS EXATOS que todos os arquivos devem usar. Mata a maior causa de build-fix
|
|
728
|
+
// do executor barato: inconsistência entre arquivos gerados em rodadas separadas (contexto zerado).
|
|
729
|
+
// Inspiração: planejar a arquitetura ANTES de codar (padrão Archify/diagrama-primeiro).
|
|
730
|
+
function looksComplex(goal) {
|
|
731
|
+
const g = String(goal || '');
|
|
732
|
+
const hits = (g.match(/\b(api|banco|integra\w*|fila|webhook|websocket|rss|sync|servidor|backend|tempo real|notifica\w*|pagamento|login|auth|nuvem|cloud|multi\w*)\b/gi) || []).length;
|
|
733
|
+
return g.length > 1500 || hits >= 3;
|
|
734
|
+
}
|
|
735
|
+
async function archPhase(goal, architect, token, opts = {}) {
|
|
736
|
+
const rules = loadRules(opts.dir);
|
|
737
|
+
const sys = 'Você é um ARQUITETO DE SOFTWARE SÊNIOR. Dado o objetivo, produza um CONTRATO DE ARQUITETURA curto e IMPLEMENTÁVEL, que vários programadores implementarão em rodadas separadas SEM conversar entre si — o contrato é a única fonte da verdade. Inclua:\n'
|
|
738
|
+
+ '1) COMPONENTES/módulos (responsabilidade única, 1 linha cada).\n'
|
|
739
|
+
+ '2) ARQUIVOS principais com CAMINHO EXATO por componente.\n'
|
|
740
|
+
+ '3) CONTRATOS EXATOS entre as partes: nomes de classes/funções com ASSINATURAS (parâmetros e retorno), nomes de tabelas/boxes/chaves de storage, rotas/endpoints — os nomes LITERAIS que todos os arquivos devem usar (isto evita referência quebrada entre arquivos).\n'
|
|
741
|
+
+ '4) FLUXO DE DADOS: quem chama quem, em que ordem (A → B → C), incluindo o caminho de erro.\n'
|
|
742
|
+
+ '5) INTEGRAÇÕES externas e onde entram no fluxo.\n'
|
|
743
|
+
+ '6) RISCOS prováveis (3 itens, com a prevenção).\n'
|
|
744
|
+
+ 'Direto e concreto, máx ~700 palavras. NADA genérico.';
|
|
745
|
+
const user = `OBJETIVO:\n${String(goal).slice(0, 2500)}` + (rules ? `\n\nREGRAS DO USUÁRIO (respeite no desenho):\n${rules.slice(0, 1200)}` : '');
|
|
746
|
+
const r = await _llmText({ token, model: architect, system: sys, user, maxTokens: 2000 });
|
|
747
|
+
return { brief: r.text, credits: r.credits };
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// FASE DE DESIGN: antes de codar, o DESIGNER (modelo forte) projeta um DESIGN SYSTEM
|
|
751
|
+
// concreto e implementável. O executor barato segue à risca → apps saem BONITOS de
|
|
752
|
+
// fábrica, não no padrão genérico. Uma chamada só, barata; resolve a qualidade visual.
|
|
753
|
+
// Se houver MOCKUP (imagem de referência, ex: gerada no Google AI Studio), o designer
|
|
754
|
+
// VÊ a imagem e projeta o design system a partir dela (grok-4.5 tem visão).
|
|
755
|
+
// Tipo de projeto pro design/regras: 'web' (Node/Express/HTML) vs 'mobile' (Flutter/Android).
|
|
756
|
+
// Usa o build detectado; se ainda não dá (pré-scaffold), infere do objetivo (sinal mobile manda).
|
|
757
|
+
function _projKind(goal, dir) {
|
|
758
|
+
try { const b = detectBuild(dir); if (b && (b.kind === 'web' || b.kind === 'node')) return 'web'; if (b && (b.kind === 'flutter' || b.kind === 'android')) return 'mobile'; } catch (_) {}
|
|
759
|
+
const g = String(goal || '');
|
|
760
|
+
if (/\b(flutter|android|kotlin|apk|ios|swift|react\s?native|jetpack|compose)\b/i.test(g)) return 'mobile';
|
|
761
|
+
if (/\b(web|express|node\.?js|html|css|portal|painel|dashboard|site|front-?end|back-?end|api\s?rest|sqlite|better-sqlite3|servidor)\b/i.test(g)) return 'web';
|
|
762
|
+
return 'mobile'; // default histórico do ts (mobile-first)
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async function designPhase(goal, designer, token, opts = {}) {
|
|
766
|
+
const web = opts.kind === 'web';
|
|
767
|
+
const sys = web
|
|
768
|
+
? ('Você é um DESIGNER DE PRODUTO WEB SÊNIOR, especialista em interfaces web bonitas, modernas e RESPONSIVAS (dark ou claro conforme o objetivo). '
|
|
769
|
+
+ 'Dado o objetivo, produza um DESIGN SYSTEM CONCRETO E IMPLEMENTÁVEL EM CSS (valores reais, nunca genérico). Inclua:\n'
|
|
770
|
+
+ '1) PALETA exata (hex) com papéis: fundo, superfície, superfície-elevada, primária, secundária, gradiente (2 hex), texto-primário, texto-secundário, sucesso, erro, aviso, borda/divisor.\n'
|
|
771
|
+
+ '2) TIPOGRAFIA: família (web-safe ou Google Font), escala em rem/px e peso para h1/h2/h3/corpo/legenda; line-height.\n'
|
|
772
|
+
+ '3) TOKENS: espaçamentos (px), raios (px), sombras (box-shadow), transições.\n'
|
|
773
|
+
+ '4) COMPONENTES com specs EXATAS em CSS: cards, botões (primário/secundário/texto: cor, HOVER, FOCUS, disabled, raio), inputs (com foco/erro), a NAVEGAÇÃO (top bar OU sidebar — cor, item ATIVO vs inativo, hover), badges/selos, tabelas/listas, modais.\n'
|
|
774
|
+
+ '5) Por TELA: notas de layout (grid/flex, largura máx do container, comportamento responsivo).\n'
|
|
775
|
+
+ '6) REGRAS DE VIDA WEB (inegociáveis — bugs reais): \n'
|
|
776
|
+
+ ' a. RESPONSIVO: nada quebra/estoura de 360px a 1440px; SEM scroll horizontal acidental.\n'
|
|
777
|
+
+ ' b. ESTADOS: todo interativo tem :hover E :focus-visible visíveis (acessibilidade + teclado).\n'
|
|
778
|
+
+ ' c. CONTRASTE: texto atinge contraste AA sobre o fundo.\n'
|
|
779
|
+
+ ' d. Nada de "Invalid Date"/"undefined"/"NaN"/"[object Object]" na tela — formate datas e valores sempre.\n'
|
|
780
|
+
+ 'Seja específico, elegante e coeso. Markdown limpo (máx ~750 palavras). É WEB (CSS) — NÃO use termos de Android (sp/dp/XML/BottomNavigationView/FAB/elevation em dp).')
|
|
781
|
+
: ('Você é um DESIGNER DE PRODUTO SÊNIOR, especialista em apps mobile bonitos, modernos e com acabamento profissional. '
|
|
782
|
+
+ 'Dado o objetivo do app, produza um DESIGN SYSTEM CONCRETO E IMPLEMENTÁVEL (valores reais, nunca genérico) para um app Android PREMIUM. Inclua:\n'
|
|
783
|
+
+ '1) PALETA exata (hex) com papéis: fundo, superfície, superfície-elevada, primária, secundária, gradiente (2 hex), texto-primário, texto-secundário, sucesso, erro, divisor.\n'
|
|
784
|
+
+ '2) TIPOGRAFIA: escala em sp e peso para display/título/subtítulo/corpo/legenda.\n'
|
|
785
|
+
+ '3) TOKENS: espaçamentos (dp), raios de canto (dp), elevações.\n'
|
|
786
|
+
+ '4) COMPONENTES com specs EXATAS: cards (raio/padding/elevação/cor), botões (primário/secundário/texto: cor, estado, raio), FAB, campos de texto, e ESPECIALMENTE a BOTTOM NAVIGATION — cor de fundo, item SELECIONADO vs NÃO-selecionado (cor de ícone E label), cor do indicador, e como o label se comporta (sempre visível, sem sobrepor). Dê o XML/estilo essencial quando ajudar.\n'
|
|
787
|
+
+ '5) Por TELA: notas curtas de layout e hierarquia visual.\n'
|
|
788
|
+
+ '6) REGRAS DE VIDA (obrigatórias, inegociáveis — bugs reais de produção):\n'
|
|
789
|
+
+ ' a. SAFE AREA: NENHUM elemento interativo (FAB, botão, item de nav) a menos de 24dp das bordas superior/inferior da tela — a barra de gestos/navegação do sistema sobrepõe. Sempre SafeArea/insets.\n'
|
|
790
|
+
+ ' b. BOTÃO VOLTAR do sistema: de uma tela/aba interna, VOLTAR deve voltar uma tela (nunca fechar o app, nunca tela preta). Rotas aninhadas/PopScope corretos.\n'
|
|
791
|
+
+ ' c. BOOT RÁPIDO: primeira tela renderiza em <2s; inicialização pesada (banco/scan/IO) é assíncrona DEPOIS do primeiro frame, com skeleton — e storages (ex: Hive) inicializados/abertos ANTES de qualquer uso.\n'
|
|
792
|
+
+ 'Seja específico, elegante e coeso. Markdown limpo e direto (máx ~750 palavras).');
|
|
793
|
+
const rules = loadRules(opts.dir);
|
|
794
|
+
const userMsg = `OBJETIVO DO APP:\n${String(goal).slice(0, 2000)}`
|
|
795
|
+
+ (rules ? `\n\nREGRAS DO USUÁRIO (lições de projetos anteriores — respeite TODAS no design):\n${rules}` : '')
|
|
796
|
+
+ (opts.mockupB64 ? '\n\nHÁ UM MOCKUP DE REFERÊNCIA em anexo: o design system deve REPRODUZIR fielmente esse mockup (paleta, estrutura, componentes, espaçamentos). Descreva-o com precisão.' : '');
|
|
797
|
+
const r = opts.mockupB64
|
|
798
|
+
? await _llmVision({ token, model: designer, text: sys + '\n\n' + userMsg, imageB64: opts.mockupB64, maxTokens: 2000 })
|
|
799
|
+
: await _llmText({ token, model: designer, system: sys, user: userMsg, maxTokens: 2000 });
|
|
800
|
+
return { brief: r.text, credits: r.credits };
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Parser tolerante: modelos pequenos às vezes devolvem JSON com fence, vírgula
|
|
804
|
+
// sobrando, ou TRUNCADO no meio (max_tokens acabou). Recupera o máximo possível.
|
|
805
|
+
function _tolerantJson(text) {
|
|
806
|
+
let s = String(text).replace(/```(?:json)?/gi, '').trim();
|
|
807
|
+
const open = s.indexOf('{');
|
|
808
|
+
if (open >= 0) s = s.slice(open);
|
|
809
|
+
try { return JSON.parse(s); } catch (_) {}
|
|
810
|
+
// 1) tenta fechar chaves/colchetes abertos (JSON truncado) + tira vírgula sobrando
|
|
811
|
+
try {
|
|
812
|
+
let t = s.replace(/,\s*([}\]])/g, '$1');
|
|
813
|
+
const opens = (t.match(/[{[]/g) || []).length, closes = (t.match(/[}\]]/g) || []).length;
|
|
814
|
+
// corta um eventual objeto pela metade no fim, depois fecha
|
|
815
|
+
if (opens > closes) { t = t.replace(/,\s*\{[^{}]*$/, ''); t += ']'.repeat(0); }
|
|
816
|
+
// fecha na ordem certa contando o que abriu
|
|
817
|
+
const stack = [];
|
|
818
|
+
for (const ch of t) { if (ch === '{') stack.push('}'); else if (ch === '[') stack.push(']'); else if (ch === '}' || ch === ']') stack.pop(); }
|
|
819
|
+
while (stack.length) t += stack.pop();
|
|
820
|
+
t = t.replace(/,\s*([}\]])/g, '$1');
|
|
821
|
+
return JSON.parse(t);
|
|
822
|
+
} catch (_) {}
|
|
823
|
+
// 2) último recurso: extrai as descrições por regex (ignora o item cortado)
|
|
824
|
+
const descs = [...s.matchAll(/"desc"\s*:\s*"((?:[^"\\]|\\.)*)"/g)].map(m => m[1].replace(/\\"/g, '"'));
|
|
825
|
+
if (descs.length) return { itens: descs.map((d, i) => ({ id: 'i' + (i + 1), desc: d })) };
|
|
826
|
+
return null;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const CHECKLIST_SYS = 'Quebre o OBJETIVO em itens VERIFICÁVEIS de entrega (3 a 15). Cada item deve ser '
|
|
830
|
+
+ 'concreto e conferível (arquivo criado, comando executado com sucesso, seção escrita) — nada vago tipo "melhorar". '
|
|
831
|
+
+ 'Responda APENAS JSON: {"itens":[{"id":"i1","desc":"..."},...]}. Ordene na sequência lógica de execução.';
|
|
832
|
+
|
|
833
|
+
// Pergunta BINÁRIA sobre o item alvo (modelo pequeno erra listas, mas acerta sim/não).
|
|
834
|
+
const MARK_SYS = 'Você é o VERIFICADOR de uma missão. Recebe UM item do checklist e o RESULTADO da rodada que tentou executá-lo. '
|
|
835
|
+
+ 'Responda APENAS JSON: {"passou":true|false,"tambem_concluidos":["ids de OUTROS itens do checklist que o resultado comprova, se houver"],"reason":"1 linha"}. '
|
|
836
|
+
+ 'passou=true SÓ com EVIDÊNCIA no resultado (arquivo com caminho, saída de comando, confirmação explícita do que o item pede) — na dúvida, passou=false.';
|
|
837
|
+
|
|
838
|
+
const fmtChecklist = (itens) => itens.map(it => `[${it.passes ? 'x' : it.blocked ? '!' : ' '}] (${it.id}) ${it.desc}`).join('\n');
|
|
839
|
+
|
|
840
|
+
async function makeChecklist(goal, token) {
|
|
841
|
+
const r = await _llmJson({ token, system: CHECKLIST_SYS, user: 'OBJETIVO:\n' + goal, maxTokens: 1600 });
|
|
842
|
+
const itens = (Array.isArray(r.json?.itens) ? r.json.itens : []).slice(0, 15)
|
|
843
|
+
.map((it, i) => ({ id: String(it.id || 'i' + (i + 1)), desc: String(it.desc || '').slice(0, 300), passes: false, attempts: 0 }))
|
|
844
|
+
.filter(it => it.desc);
|
|
845
|
+
return { itens: itens.length ? itens : [{ id: 'i1', desc: String(goal).slice(0, 300), passes: false, attempts: 0 }], credits: r.credits };
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Roda (ou retoma) a missão do diretório atual.
|
|
850
|
+
* opts: token, lang, yes, budget, maxRounds, dir,
|
|
851
|
+
* onChecklist(itens), onRound({n,item}), onRoundDone({marks,credits}),
|
|
852
|
+
* onStep/askApprove/onRemote (repassados pro agente)
|
|
853
|
+
* Retorna o estado final {status: done|paused, ...}.
|
|
854
|
+
*/
|
|
855
|
+
async function run(goal, opts = {}) {
|
|
856
|
+
const { token, lang = 'pt', yes = false, budget = 400, maxRounds = 20, dir = process.cwd(), model = null, thinker = null, maxMinutes = 0, designer = null, design = 'auto', runGate = true, eye = null, visualLadder = true } = opts;
|
|
857
|
+
const onChecklist = opts.onChecklist || (() => {});
|
|
858
|
+
const onRound = opts.onRound || (() => {});
|
|
859
|
+
const onRoundDone = opts.onRoundDone || (() => {});
|
|
860
|
+
const onAlert = opts.onAlert || (() => {}); // avisos (escalonei / preciso de você / travei)
|
|
861
|
+
const startMs = Date.now();
|
|
862
|
+
|
|
863
|
+
let st = load(dir);
|
|
864
|
+
if (!st || st.status === 'done') {
|
|
865
|
+
if (!goal) return null; // nada pra retomar
|
|
866
|
+
// SCAFFOLD (base oficial): projeto novo de tipo conhecido nasce do gerador oficial
|
|
867
|
+
let scaffoldCred = 0;
|
|
868
|
+
try { const sc = scaffold(goal, dir, onAlert); if (sc) onAlert({ type: 'setup', text: `🏗 ts: base ${sc.kind} criada pelo gerador oficial (menos erro de build).` }); } catch (_) {}
|
|
869
|
+
// FASE DE VIABILIDADE: avalia o POSSÍVEL e escopa honesto antes de codar app ambicioso
|
|
870
|
+
let feasBrief = null, feasCred = 0;
|
|
871
|
+
const feasMode = opts.feasibility === undefined ? 'auto' : opts.feasibility;
|
|
872
|
+
const feasModel = thinker || 'grok-4.5';
|
|
873
|
+
if (feasMode === true || (feasMode === 'auto' && looksComplex(goal))) {
|
|
874
|
+
onRound({ n: 0, item: (lang === 'en' ? `Checking feasibility with ${feasModel}…` : `Avaliando viabilidade com ${feasModel}…`), attempt: 1 });
|
|
875
|
+
try { const fe = await feasibilityPhase(goal, feasModel, token); feasBrief = fe.brief; feasCred = fe.credits || 0; try { fs.writeFileSync(path.join(dir, 'VIABILIDADE.md'), feasBrief); } catch (_) {} onAlert({ type: 'design', text: `🔎 ts: viabilidade avaliada (${feasModel}, ${feasCred} créditos) — o que dá e o que não dá está fixado. Salvo em VIABILIDADE.md.` }); } catch (_e) { if (_e && _e.code === 'no_credits') throw _e; }
|
|
876
|
+
}
|
|
877
|
+
// FASE DE DESIGN (nova missão visual): o designer forte projeta o design system
|
|
878
|
+
// ANTES do checklist e do código, pra o app sair bonito de fábrica.
|
|
879
|
+
let designBrief = null, designCred = 0;
|
|
880
|
+
const wantDesign = design === true || (design === 'auto' && looksVisual(goal));
|
|
881
|
+
if (wantDesign && designer) {
|
|
882
|
+
onRound({ n: 0, item: (lang === 'en' ? `Designing the visual with ${designer}…` : `Projetando o visual com ${designer}…`), attempt: 1 });
|
|
883
|
+
let mockupB64 = null;
|
|
884
|
+
if (opts.mockup) { try { mockupB64 = fs.readFileSync(opts.mockup).toString('base64'); onAlert({ type: 'design', text: `🖼 ts: mockup de referência carregado (${path.basename(opts.mockup)}) — o designer vai projetar a partir dele e o olho vai cobrar fidelidade.` }); } catch (_) { onAlert({ type: 'design', text: `⚠ ts: não consegui ler o mockup ${opts.mockup} — seguindo sem ele.` }); } }
|
|
885
|
+
try { const d = await designPhase(goal, designer, token, { dir, mockupB64, kind: _projKind(goal, dir) }); designBrief = d.brief; designCred = d.credits || 0; try { fs.writeFileSync(path.join(dir, 'DESIGN.md'), designBrief); } catch (_) {} onAlert({ type: 'design', text: `🎨 ts: design system criado pelo ${designer} (${designCred} créditos) — o app vai seguir esse visual. Salvo em DESIGN.md.` }); } catch (_e) { if (_e && _e.code === 'no_credits') throw _e; }
|
|
886
|
+
}
|
|
887
|
+
// FASE DE ARQUITETURA (3º pilar): app complexo ganha um CONTRATO antes do checklist
|
|
888
|
+
let archBrief = null, archCred = 0;
|
|
889
|
+
const archMode = opts.arch === undefined ? 'auto' : opts.arch;
|
|
890
|
+
const archModel = thinker || 'grok-4.5';
|
|
891
|
+
if (archMode === true || (archMode === 'auto' && looksComplex(goal))) {
|
|
892
|
+
onRound({ n: 0, item: (lang === 'en' ? `Designing the architecture with ${archModel}…` : `Projetando a arquitetura com ${archModel}…`), attempt: 1 });
|
|
893
|
+
try { const a = await archPhase(goal, archModel, token, { dir }); archBrief = a.brief; archCred = a.credits || 0; try { fs.writeFileSync(path.join(dir, 'ARQUITETURA.md'), archBrief); } catch (_) {} onAlert({ type: 'design', text: `📐 ts: contrato de arquitetura criado pelo ${archModel} (${archCred} créditos) — componentes, arquivos e assinaturas fixados. Salvo em ARQUITETURA.md.` }); } catch (_e) { if (_e && _e.code === 'no_credits') throw _e; }
|
|
894
|
+
}
|
|
895
|
+
const mk = await makeChecklist(goal + (designBrief ? '\n\n[Há um DESIGN SYSTEM definido — o checklist deve refletir a aplicação desse visual]' : '') + (archBrief ? '\n\n[Há um CONTRATO DE ARQUITETURA definido — os itens devem seguir os componentes/arquivos dele]' : ''), token);
|
|
896
|
+
st = { goal: String(goal).slice(0, 4000), mockup: opts.mockup || null, archBrief, feasBrief, checklist: mk.itens, rounds: [], creditsSpent: (mk.credits || 0) + designCred + archCred + feasCred,
|
|
897
|
+
budget, maxRounds, status: 'running', model: model || null, designBrief: designBrief || null, created_at: new Date().toISOString() };
|
|
898
|
+
save(st, dir);
|
|
899
|
+
} else {
|
|
900
|
+
st.status = 'running';
|
|
901
|
+
if (opts.addBudget) st.budget += opts.addBudget;
|
|
902
|
+
else if (st.creditsSpent >= st.budget) st.budget = st.creditsSpent + budget; // nova janela ao retomar
|
|
903
|
+
}
|
|
904
|
+
const useModel = model || st.model || null; // executor fixo persiste entre retomadas
|
|
905
|
+
const useThinker = thinker || st.thinker || null; // modelo caro "pensador" (escalonamento)
|
|
906
|
+
if (thinker && !st.thinker) { st.thinker = thinker; save(st, dir); }
|
|
907
|
+
onChecklist(st.checklist);
|
|
908
|
+
|
|
909
|
+
const _sig = (s) => String(s || '').replace(/[0-9]/g, '#').slice(0, 200); // assinatura do erro (ignora números de linha)
|
|
910
|
+
|
|
911
|
+
for (;;) {
|
|
912
|
+
// ── FREIO DE RELÓGIO (watchdog): encerra se passar do tempo máximo ──
|
|
913
|
+
if (maxMinutes > 0 && (Date.now() - startMs) > maxMinutes * 60000) {
|
|
914
|
+
st.status = 'paused'; st.pause_reason = 'timeout'; save(st, dir);
|
|
915
|
+
onAlert({ type: 'timeout', text: `⏱ ts: missão pausada por tempo (${maxMinutes} min). Retome com "ts meta".` });
|
|
916
|
+
return st;
|
|
917
|
+
}
|
|
918
|
+
let pend = st.checklist.filter(it => !it.passes && !it.blocked);
|
|
919
|
+
// PORTÃO DE BUILD/RUN/VISUAL: projeto compilável só termina quando compila DE VERDADE,
|
|
920
|
+
// ABRE sem crashar E o "olho" aprova o layout. O portão REENTRA enquanto o visual não passa.
|
|
921
|
+
if (!pend.length) {
|
|
922
|
+
const b = detectBuild(dir);
|
|
923
|
+
const buildCapLeft = (st.buildFixes || 0) < MAX_BUILD_FIXES;
|
|
924
|
+
// teto de polimento visual: menor pra Flutter (rebuild caro/lento) que pra Android
|
|
925
|
+
const visualCap = (b && b.kind === 'flutter') ? MAX_VISUAL_FIXES_FLUTTER : MAX_VISUAL_FIXES;
|
|
926
|
+
const visualCapLeft = !!eye && (st.visualFixes || 0) < visualCap;
|
|
927
|
+
// Roda o portão se: (a) ainda não compilou de verdade, OU (b) compilou/abriu mas o VISUAL ainda não foi aprovado.
|
|
928
|
+
const gateNeeded = b && ((!st.buildVerified && buildCapLeft) || (st.buildVerified && !st.visualOk && visualCapLeft));
|
|
929
|
+
if (gateNeeded) {
|
|
930
|
+
let errText = null; // preenchido só em erro REAL de build/crash (dispara escalonamento); visual usa outro caminho
|
|
931
|
+
onRound({ n: st.rounds.length + 1, item: (lang === 'en' ? 'Compiling for real (build gate)…' : 'Compilando de verdade (portão de build)…'), attempt: 1 });
|
|
932
|
+
ensureToolchain(b, onAlert);
|
|
933
|
+
// ANÁLISE ESTÁTICA (barata) ANTES do build pesado — pega erro de código de graça
|
|
934
|
+
let r = null;
|
|
935
|
+
if (!st.buildVerified) {
|
|
936
|
+
const sc = staticCheck(b);
|
|
937
|
+
if (sc && !sc.ok) { onRound({ n: st.rounds.length + 1, item: (lang === 'en' ? 'Static analysis found errors (fixing before the heavy build)…' : 'Análise estática achou erros (corrigindo antes do build pesado)…'), attempt: 1 }); r = { ok: false, out: sc.out }; }
|
|
938
|
+
}
|
|
939
|
+
if (!r) r = runBuild(b);
|
|
940
|
+
if (r.ok) {
|
|
941
|
+
// ── RUN GATE: compilou → ABRE o app de verdade e vê se crasha (só se houver
|
|
942
|
+
// emulador/aparelho e o gate estiver ligado). Crash de runtime vira "erro" pro executor.
|
|
943
|
+
// Vale pra Android (Gradle) E Flutter — ambos geram APK instalável no emulador.
|
|
944
|
+
if (runGate && (b.kind === 'android' || b.kind === 'flutter')) {
|
|
945
|
+
onRound({ n: st.rounds.length + 1, item: (lang === 'en' ? 'Launching the app on the emulator (run gate)…' : 'Abrindo o app no emulador (run gate)…'), attempt: 1 });
|
|
946
|
+
const rg = runApp(b, dir, onAlert);
|
|
947
|
+
if (rg.skipped) { st.buildVerified = true; st.visualOk = true; st.status = 'done'; st.runNote = 'run gate pulado: ' + rg.reason; st.finished_at = new Date().toISOString(); save(st, dir); onRoundDone({ checklist: st.checklist, spent: st.creditsSpent }); return st; }
|
|
948
|
+
if (rg.ok) {
|
|
949
|
+
st.buildVerified = true; st.runVerified = true;
|
|
950
|
+
// ── VISUAL GATE: app abre → tira print e o "olho" critica o layout.
|
|
951
|
+
// Reprovou → vira "erro" pro executor melhorar; portão REENTRA (rebuild+print) até aprovar ou estourar o teto.
|
|
952
|
+
if (visualCapLeft) {
|
|
953
|
+
onRound({ n: st.rounds.length + 1, item: (lang === 'en' ? `Reviewing the visual with ${eye} (visual gate)…` : `Avaliando o visual com ${eye} (visual gate)…`), attempt: 1 });
|
|
954
|
+
let vg = { ok: true };
|
|
955
|
+
let mockB64 = null; if (st.mockup) { try { mockB64 = fs.readFileSync(st.mockup).toString('base64'); } catch (_) {} }
|
|
956
|
+
try { vg = await visualGate(b, dir, eye, token, st.designBrief, mockB64); } catch (_) {}
|
|
957
|
+
st.creditsSpent += vg.credits || 0;
|
|
958
|
+
if (!vg.ok && !vg.skipped && vg.critique) {
|
|
959
|
+
st.visualFixes = (st.visualFixes || 0) + 1;
|
|
960
|
+
st.buildError = 'O app compila e ABRE, mas o VISUAL foi REPROVADO por um diretor de arte rigoroso (print real do app rodando). Corrija EXATAMENTE os pontos abaixo editando os XML de layout/tema/drawables — não invente APIs, garanta que o XML compila, e capriche pra ficar UAU:\n' + vg.critique;
|
|
961
|
+
onAlert({ type: 'design', text: `👁 ts: revisão visual (${eye}) achou defeito ESTRUTURAL no layout (${st.visualFixes}/${visualCap}). Mandando o executor corrigir e vou reavaliar.` });
|
|
962
|
+
// vira item de correção (loop igual ao build); NÃO dispara escalonamento de build
|
|
963
|
+
let vfix = st.checklist.find(it => it.id === 'visual_fix');
|
|
964
|
+
if (!vfix) { vfix = { id: 'visual_fix', desc: (lang === 'en' ? 'Polish the layout until the art director approves the visual' : 'Polir o layout até o diretor de arte aprovar o visual'), passes: false, attempts: 0, isBuild: true }; st.checklist.push(vfix); }
|
|
965
|
+
vfix.passes = false; vfix.blocked = false; vfix.attempts = 0;
|
|
966
|
+
save(st, dir);
|
|
967
|
+
onRoundDone({ checklist: st.checklist, spent: st.creditsSpent, buildFailed: true });
|
|
968
|
+
pend = [vfix];
|
|
969
|
+
} else {
|
|
970
|
+
// Aprovado: impecável OU só com nitpicks cosméticos (não vale queimar rodada cara).
|
|
971
|
+
st.visualOk = true;
|
|
972
|
+
if (vg.severity === 'ressalvas') { st.runNote = (st.runNote || '') + ' visual aprovado com ressalvas cosméticas (sem defeito estrutural)'; onAlert({ type: 'design', text: `👁 ts: visual APROVADO — sobrou só nitpick cosmético (sem defeito estrutural). Não vou gastar polimento caro à toa.` }); }
|
|
973
|
+
st.status = 'done'; st.finished_at = new Date().toISOString(); save(st, dir); onRoundDone({ checklist: st.checklist, spent: st.creditsSpent }); return st;
|
|
974
|
+
}
|
|
975
|
+
} else { st.visualOk = true; st.status = 'done'; st.finished_at = new Date().toISOString(); save(st, dir); onRoundDone({ checklist: st.checklist, spent: st.creditsSpent }); return st; }
|
|
976
|
+
} else {
|
|
977
|
+
// CRASHOU ao abrir → trata como erro pro executor corrigir (loop igual ao build)
|
|
978
|
+
st.buildFixes = (st.buildFixes || 0) + 1;
|
|
979
|
+
errText = 'CRASH DE RUNTIME (o app compila mas FECHA ao abrir). Corrija a CAUSA-RAIZ no código (não é erro de build):\n' + rg.crash;
|
|
980
|
+
st.buildError = errText;
|
|
981
|
+
}
|
|
982
|
+
} else if (runGate && b.kind === 'web') {
|
|
983
|
+
// ── GATE WEB: sobe o servidor de verdade + Chrome headless (console/erros/print).
|
|
984
|
+
onRound({ n: st.rounds.length + 1, item: (lang === 'en' ? 'Starting the web server and testing in the browser (web gate)…' : 'Subindo o servidor web e testando no navegador (gate web)…'), attempt: 1 });
|
|
985
|
+
let wg = { ok: true }; try { wg = await webRunGate(b); } catch (_) {}
|
|
986
|
+
if (wg.ok) {
|
|
987
|
+
st.buildVerified = true; st.runVerified = true; st.visualOk = true;
|
|
988
|
+
if (wg.shot) st.webShot = wg.shot;
|
|
989
|
+
st.status = 'done'; st.finished_at = new Date().toISOString(); save(st, dir); onRoundDone({ checklist: st.checklist, spent: st.creditsSpent }); return st;
|
|
990
|
+
} else {
|
|
991
|
+
st.buildFixes = (st.buildFixes || 0) + 1;
|
|
992
|
+
errText = 'GATE WEB — o servidor precisa SUBIR e a página abrir SEM erro de console/runtime. Corrija a CAUSA-RAIZ (backend ou frontend):\n' + wg.crash;
|
|
993
|
+
st.buildError = errText;
|
|
994
|
+
}
|
|
995
|
+
} else {
|
|
996
|
+
st.buildVerified = true; st.visualOk = true; st.status = 'done'; st.finished_at = new Date().toISOString(); save(st, dir); onRoundDone({ checklist: st.checklist, spent: st.creditsSpent }); return st;
|
|
997
|
+
}
|
|
998
|
+
} else { st.buildFixes = (st.buildFixes || 0) + 1; errText = r.out; st.buildError = r.out; }
|
|
999
|
+
// ── ESCALONAMENTO: só em erro REAL de build/crash. Mesmo erro de novo → PENSADOR caro diagnostica ──
|
|
1000
|
+
if (errText) {
|
|
1001
|
+
const sig = _sig(st.buildError);
|
|
1002
|
+
st.escalationHint = null;
|
|
1003
|
+
if (useThinker && sig === st.lastBuildSig && (st.escalations || 0) < MAX_ESCALATIONS) {
|
|
1004
|
+
onRound({ n: st.rounds.length + 1, item: (lang === 'en' ? `Stuck — escalating to ${useThinker} to diagnose…` : `Travado — escalando pro ${useThinker} diagnosticar…`), attempt: 1 });
|
|
1005
|
+
try {
|
|
1006
|
+
const e = await escalate(errText, dir, useThinker, token);
|
|
1007
|
+
st.escalations = (st.escalations || 0) + 1; st.creditsSpent += e.credits || 0; st.escalationHint = e.hint;
|
|
1008
|
+
onAlert({ type: 'escalate', text: `🧠 ts: travei num erro, escalei pro ${useThinker} (${st.escalations}/${MAX_ESCALATIONS}). Aplicando o diagnóstico.` });
|
|
1009
|
+
} catch (_) {}
|
|
1010
|
+
}
|
|
1011
|
+
st.lastBuildSig = sig;
|
|
1012
|
+
let fixItem = st.checklist.find(it => it.id === 'build_fix');
|
|
1013
|
+
if (!fixItem) { fixItem = { id: 'build_fix', desc: (lang === 'en' ? 'Fix compile errors until the build succeeds and the APK is generated' : 'Corrigir os erros de compilação até o build passar e o APK ser gerado'), passes: false, attempts: 0, isBuild: true }; st.checklist.push(fixItem); }
|
|
1014
|
+
fixItem.passes = false; fixItem.blocked = false;
|
|
1015
|
+
save(st, dir);
|
|
1016
|
+
onRoundDone({ checklist: st.checklist, spent: st.creditsSpent, buildFailed: true });
|
|
1017
|
+
pend = [fixItem];
|
|
1018
|
+
}
|
|
1019
|
+
} else if (!st.buildVerified || (eye && !st.visualOk)) {
|
|
1020
|
+
// Portão precisava rodar mas estourou o teto (build ou visual): encerra registrando a limitação.
|
|
1021
|
+
st.buildVerified = true; st.visualOk = true;
|
|
1022
|
+
st.status = 'done'; st.finished_at = new Date().toISOString();
|
|
1023
|
+
st.runNote = (st.runNote || '') + (visualCapLeft ? '' : ' visual: teto de polimentos atingido');
|
|
1024
|
+
save(st, dir); onRoundDone({ checklist: st.checklist, spent: st.creditsSpent }); return st;
|
|
1025
|
+
} else {
|
|
1026
|
+
st.status = 'done'; st.finished_at = new Date().toISOString(); save(st, dir);
|
|
1027
|
+
return st;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
if (st.rounds.length >= st.maxRounds) { st.status = 'paused'; st.pause_reason = 'max_rounds'; save(st, dir); return st; }
|
|
1031
|
+
if (st.creditsSpent >= st.budget) { st.status = 'paused'; st.pause_reason = 'budget'; save(st, dir); onAlert({ type: 'budget', text: `💳 ts: teto de créditos (${st.budget}) atingido. Retome com "ts meta".` }); return st; }
|
|
1032
|
+
// ── WATCHDOG DE ESTAGNAÇÃO: girando no mesmo lugar (0 item novo + mesmo erro) → corta ──
|
|
1033
|
+
// inclui visualFixes: cada polimento visual conta como progresso (senão o loop visual, que faz o item oscilar
|
|
1034
|
+
// com o mesmo lastBuildSig, dispararia estagnação falsa). NÃO inclui buildFixes — senão mascararia a estagnação real de build.
|
|
1035
|
+
const fp = st.checklist.filter(i => i.passes).length + '|' + (st.lastBuildSig || '') + '|v' + (st.visualFixes || 0);
|
|
1036
|
+
if (fp === st.lastFp) { st.stagnant = (st.stagnant || 0) + 1; } else { st.stagnant = 0; st.lastFp = fp; }
|
|
1037
|
+
if (st.stagnant >= STAGNATION_LIMIT) {
|
|
1038
|
+
st.status = 'paused'; st.pause_reason = 'stagnated'; save(st, dir);
|
|
1039
|
+
onAlert({ type: 'stagnated', text: `🛑 ts: a missão travou (sem progresso em ${STAGNATION_LIMIT} rodadas). Parei pra não desperdiçar créditos. Precisa de você: "${st.buildError ? String(st.buildError).replace(/\s+/g, ' ').slice(0, 140) : st.goal.slice(0, 100)}"` });
|
|
1040
|
+
return st;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const item = pend[0];
|
|
1044
|
+
item.attempts = (item.attempts || 0) + 1;
|
|
1045
|
+
onRound({ n: st.rounds.length + 1, item: item.desc, attempt: item.attempts });
|
|
1046
|
+
|
|
1047
|
+
// Rodada com CONTEXTO ZERADO: o agente recebe só o essencial (objetivo resumido +
|
|
1048
|
+
// estado do checklist + resultado da rodada anterior) — nunca o histórico inteiro.
|
|
1049
|
+
const last = st.rounds[st.rounds.length - 1];
|
|
1050
|
+
const files = projectMap(dir);
|
|
1051
|
+
const mapBlock = files.length
|
|
1052
|
+
? (lang === 'en' ? `\nPROJECT FILES (already exist — go straight to the one you need, do NOT list/search dirs):\n` : `\nARQUIVOS DO PROJETO (já existem — vá direto ao que precisa, NÃO liste/busque diretórios):\n`) + files.join('\n') + '\n'
|
|
1053
|
+
: '';
|
|
1054
|
+
// DESIGN SYSTEM: injeta o brief do designer em TODA rodada — o executor implementa
|
|
1055
|
+
// exatamente esse visual (cores/tokens/componentes), sem cair no Material padrão.
|
|
1056
|
+
const designBlock = st.designBrief
|
|
1057
|
+
? (lang === 'en' ? `\nDESIGN SYSTEM (a senior designer defined this — FOLLOW IT EXACTLY: colors, tokens, component specs, especially the bottom navigation):\n${String(st.designBrief).slice(0, 3500)}\n` : `\nDESIGN SYSTEM (um designer sênior definiu isto — SIGA À RISCA: cores, tokens, specs de cada componente, especialmente a bottom navigation):\n${String(st.designBrief).slice(0, 3500)}\n`)
|
|
1058
|
+
: '';
|
|
1059
|
+
// REGRAS DE VIDA do usuário (~/.ts/regras.md + .ts-regras.md): lições que valem em TODA rodada
|
|
1060
|
+
const rulesTxt = loadRules(dir);
|
|
1061
|
+
const rulesBlock = rulesTxt ? `\nREGRAS DO USUÁRIO (lições de projetos anteriores — OBRIGATÓRIAS):\n${rulesTxt}\n` : '';
|
|
1062
|
+
// CONTRATO DE ARQUITETURA: nomes/assinaturas/arquivos EXATOS — evita referência quebrada entre rodadas
|
|
1063
|
+
const archBlock = st.archBrief
|
|
1064
|
+
? `\nCONTRATO DE ARQUITETURA (um arquiteto sênior fixou isto — USE OS NOMES, ASSINATURAS E CAMINHOS LITERALMENTE, nunca invente variações):\n${String(st.archBrief).slice(0, 2500)}\n`
|
|
1065
|
+
: '';
|
|
1066
|
+
// VIABILIDADE: o que dá e o que NÃO dá — implemente a versão realista, não tente o impossível
|
|
1067
|
+
const feasBlock = st.feasBrief
|
|
1068
|
+
? `\nVIABILIDADE (respeite os limites técnicos — implemente a VERSÃO REALISTA das partes impossíveis, não perca rodadas tentando o que a plataforma não permite):\n${String(st.feasBrief).slice(0, 1200)}\n`
|
|
1069
|
+
: '';
|
|
1070
|
+
// SEED (app web com login): o gate web VERIFICA logando com um usuário de seed. Sem um seed
|
|
1071
|
+
// funcional de credenciais FIXAS, as telas internas ficam sem teste. Regra injetada só em web.
|
|
1072
|
+
const seedBlock = _projKind(st.goal, dir) === 'web'
|
|
1073
|
+
? (lang === 'en'
|
|
1074
|
+
? `\nIF THE APP HAS LOGIN/AUTH: in the initial seed, create a DEMO user with FIXED, KNOWN credentials in PLAINTEXT in the code (e.g. email "demo@demo.com" / password "demo1234"), already with some sample data, and make sure login works with it. The system VERIFIES the app by LOGGING IN with that user — without a working seed the internal screens (dashboard etc.) cannot be tested.\n`
|
|
1075
|
+
: `\nSE O APP TIVER LOGIN/AUTENTICAÇÃO: no seed inicial, crie um usuário DEMO com credenciais FIXAS e conhecidas em TEXTO no código (ex: email "demo@demo.com" / senha "demo1234"), já com alguns dados de exemplo, e garanta que o login funciona com ele. O sistema VERIFICA o app LOGANDO com esse usuário — sem um seed funcional as telas internas (dashboard etc.) não têm como ser testadas.\n`)
|
|
1076
|
+
: '';
|
|
1077
|
+
// CÓDIGO MÍNIMO (anti-overengineering, estilo "programador preguiçoso" — ideia da skill
|
|
1078
|
+
// Ponytail): escada de perguntas ANTES de codar → menos código = menos tokens = menos
|
|
1079
|
+
// bugs = mais barato. Reportado na origem: -54% de código e -20% de custo, mesmo resultado.
|
|
1080
|
+
const minimalBlock = (lang === 'en'
|
|
1081
|
+
? `\nMINIMAL-CODE RULES (economy — mandatory, think like a pragmatic lazy senior):\n1) Does this really need to exist? If not, don't build it.\n2) Does it already exist in this project? If yes, REUSE it — never rewrite a second version.\n3) Does the language/platform/browser already do it natively? If yes, use the native thing (e.g. <input type="date"> instead of a datepicker lib; fetch instead of axios; CSS instead of a JS animation lib).\n4) Only then write code — the MINIMUM that solves the item. NO extra library without real need, no gratuitous abstractions/wrappers, no speculative "future-proofing".\n`
|
|
1082
|
+
: `\nREGRAS DE CÓDIGO MÍNIMO (economia — obrigatórias; pense como um sênior pragmático e "preguiçoso"):\n1) Isso precisa mesmo existir? Se não precisa, NÃO construa.\n2) Já existe neste projeto? Se sim, REUSE — nunca escreva uma segunda versão.\n3) A linguagem/plataforma/navegador já faz isso nativo? Se sim, use o nativo (ex: <input type="date"> em vez de lib de datepicker; fetch em vez de axios; CSS em vez de lib JS de animação).\n4) Só então escreva código — o MÍNIMO que resolve o item. SEM biblioteca extra sem necessidade real, sem abstração/wrapper gratuito, sem "preparar pro futuro" especulativo.\n`);
|
|
1083
|
+
const task = feasBlock + archBlock + designBlock + seedBlock + rulesBlock + minimalBlock + (lang === 'en'
|
|
1084
|
+
? `Bigger goal (context — do NOT do everything now):\n${st.goal.slice(0, 800)}\n\nCHECKLIST (state):\n${fmtChecklist(st.checklist)}\n${mapBlock}`
|
|
1085
|
+
: `Objetivo maior (contexto — NÃO faça tudo agora):\n${st.goal.slice(0, 800)}\n\nCHECKLIST (estado):\n${fmtChecklist(st.checklist)}\n${mapBlock}`)
|
|
1086
|
+
+ (last ? `\n${lang === 'en' ? 'Previous round' : 'Rodada anterior'}: ${String(last.result || '').slice(0, 600)}\n` : '')
|
|
1087
|
+
+ (item.attempts > 1
|
|
1088
|
+
? (lang === 'en' ? `\nPREVIOUS ATTEMPT at this item FAILED — use a DIFFERENT approach now.\n` : `\nA tentativa ANTERIOR deste item FALHOU — use uma abordagem DIFERENTE agora.\n`)
|
|
1089
|
+
: '')
|
|
1090
|
+
+ (item.id === 'visual_fix' && st.buildError
|
|
1091
|
+
? (lang === 'en' ? `\nAn ART DIRECTOR reviewed REAL screenshots and REJECTED the layout. Fix ONLY the points below by editing the layout/theme/drawable XML. RULES: fix the exact issue, do NOT redesign or add decorative backgrounds/blobs/ellipses behind rows (that makes it uglier); for clipped horizontal rows use a real HorizontalScrollView/RecyclerView with android:clipToPadding="false" and equal start/end padding so first AND last items show fully; keep it clean and minimal. Do NOT run the build yourself (the system re-checks and re-screenshots automatically):\n${st.buildError}\n` : `\nUm DIRETOR DE ARTE revisou PRINTS REAIS e REPROVOU o layout. Corrija APENAS os pontos abaixo editando os XML de layout/tema/drawable. REGRAS: conserte exatamente o problema, NÃO redesenhe nem adicione fundos/blobs/elipses decorativos atrás das fileiras (fica mais feio); pra fileira horizontal cortada use um HorizontalScrollView/RecyclerView de verdade com android:clipToPadding="false" e padding start/end IGUAIS pra o 1º E o último item aparecerem inteiros; mantenha limpo e minimalista. NÃO rode o build você mesmo (o sistema recompila e reprinta sozinho):\n${st.buildError}\n`)
|
|
1092
|
+
: '')
|
|
1093
|
+
+ (item.isBuild && item.id !== 'visual_fix' && st.buildError
|
|
1094
|
+
? (lang === 'en' ? `\nThe REAL build just FAILED with these errors — fix the ROOT CAUSE (missing resource/import/file, wrong reference, version). Edit the files, do NOT re-run the build yourself (the system re-checks automatically):\n${st.buildError}\n` : `\nO build REAL acabou de FALHAR com estes erros — corrija a CAUSA-RAIZ (recurso/import/arquivo faltando, referência errada, versão). EDITE os arquivos; NÃO rode o build você mesmo (o sistema recompila sozinho):\n${st.buildError}\n`)
|
|
1095
|
+
: '')
|
|
1096
|
+
+ (item.isBuild && st.escalationHint
|
|
1097
|
+
? (lang === 'en' ? `\nA SENIOR ENGINEER diagnosed the root cause and the exact fix — APPLY IT precisely:\n${st.escalationHint}\n` : `\nUm ENGENHEIRO SÊNIOR diagnosticou a causa-raiz e o fix exato — APLIQUE EXATAMENTE isto:\n${st.escalationHint}\n`)
|
|
1098
|
+
: '')
|
|
1099
|
+
+ (lang === 'en'
|
|
1100
|
+
? `\nExecute NOW only this item: ${item.desc}\nBe DIRECT: do the item and stop — do NOT re-read or re-verify the whole project (wastes credits). At the end, state the full path of each file you created/changed.`
|
|
1101
|
+
: `\nExecute AGORA apenas este item: ${item.desc}\nSeja DIRETO: faça o item e pare — NÃO releia nem revise o projeto inteiro (gasta créditos à toa). NUNCA use a ferramenta preciso_de_voce pra: pedir TESTE/instalação (o SISTEMA compila/instala/abre/testa sozinho a cada rodada), pedir CAMINHO DO SDK/Flutter/toolchain (o sistema provisiona sozinho — o SDK está no objetivo e o build gate cuida do local.properties), nem pedir QUALQUER informação que já esteja no objetivo. Se um caminho/valor está no objetivo, USE-O; não pergunte. A ferramenta preciso_de_voce é SÓ pra ação externa impossível pro agente (chave de API paga, criar conta, pagamento). Na dúvida, AJA com o que tem — não pare pra perguntar. No final, informe o caminho completo de cada arquivo criado/alterado.`);
|
|
1102
|
+
|
|
1103
|
+
// RESILIÊNCIA: um blip de rede (conn) NÃO pode matar a missão inteira (crítico pra
|
|
1104
|
+
// rodar a noite). Retenta a rodada até 3x com espera crescente; se persistir, PAUSA
|
|
1105
|
+
// resumível (o estado e os arquivos ficam salvos; "ts meta" continua).
|
|
1106
|
+
// ── ESCADA VISUAL: o deepseek (barato) VÊ certo mas ERRA o fix de layout complexo
|
|
1107
|
+
// (achado real: envolveu ícones num "ovo" roxo, cores continuaram cortadas). Depois de
|
|
1108
|
+
// VISUAL_ESCALATE_AT reprovações do olho no MESMO item de layout, a MÃO do fix passa a
|
|
1109
|
+
// ser o modelo forte (o pensador/olho, grok-4.5) — que sabe compor layout. Volta ao
|
|
1110
|
+
// barato assim que o olho aprovar (visual_fix some do checklist). Só nas rodadas visuais.
|
|
1111
|
+
let roundModel = useModel;
|
|
1112
|
+
if (visualLadder && item.id === 'visual_fix' && (st.visualFixes || 0) >= VISUAL_ESCALATE_AT && (useThinker || eye)) {
|
|
1113
|
+
roundModel = useThinker || eye;
|
|
1114
|
+
onAlert({ type: 'escalate', text: `🎨 ts: o olho reprovou o layout ${st.visualFixes}x — passando a MÃO do fix visual pro ${roundModel} (mais forte em layout) até aprovar.` });
|
|
1115
|
+
}
|
|
1116
|
+
let out = null, connErr = null;
|
|
1117
|
+
for (let att = 0; att < 3 && !out; att++) {
|
|
1118
|
+
try { out = await agent.run(task, { token, lang, yes, model: roundModel, confineDir: dir, skipSessionStart: true, onStep: opts.onStep, askApprove: opts.askApprove, onRemote: opts.onRemote, onThinking: opts.onThinking }); }
|
|
1119
|
+
catch (e) {
|
|
1120
|
+
connErr = e;
|
|
1121
|
+
// teto de IA estourado → PAUSA limpa e resumível com CTA de upgrade (nunca segue em silêncio)
|
|
1122
|
+
if (e && e.code === 'no_credits') {
|
|
1123
|
+
st.status = 'paused'; st.pause_reason = 'no_credits'; save(st, dir);
|
|
1124
|
+
onAlert({ type: 'no_credits', text: `💳 ts: seu limite de IA acabou — a missão foi PAUSADA e salva. Assine um plano em terminalsmart.com.br/planos e retome com "ts meta".` });
|
|
1125
|
+
return st;
|
|
1126
|
+
}
|
|
1127
|
+
if (e && (e.code === 'conn' || String(e.message).includes('fetch') || String(e.message).includes('network'))) { onAlert({ type: 'retry', text: `📡 ts: falha de conexão na rodada — tentando de novo (${att + 1}/3)…` }); await new Promise(r => setTimeout(r, 4000 * (att + 1))); }
|
|
1128
|
+
else throw e; // erro não-transitório: sobe
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
if (!out) { st.status = 'paused'; st.pause_reason = 'connection'; save(st, dir); onAlert({ type: 'conn', text: `📡 ts: sem conexão com o servidor após 3 tentativas. Missão PAUSADA e salva. Retome com "ts meta" quando a internet voltar.` }); return st; }
|
|
1132
|
+
st.creditsSpent += out.credits || 0;
|
|
1133
|
+
|
|
1134
|
+
// ── BLOQUEIO HUMANO: o agente pediu uma ação externa que só o usuário faz ──
|
|
1135
|
+
// A missão PAUSA (gasto zero enquanto espera) e chama o usuário. Retoma com "ts meta".
|
|
1136
|
+
if (out.needHuman) {
|
|
1137
|
+
st.status = 'paused'; st.pause_reason = 'awaiting_human';
|
|
1138
|
+
st.humanRequest = { motivo: out.needHuman.motivo, o_que_fazer: out.needHuman.o_que_fazer, at: new Date().toISOString() };
|
|
1139
|
+
st.rounds.push({ item: item.desc, id: item.id, result: '[aguardando ação humana] ' + out.needHuman.motivo, credits: out.credits || 0, steps: out.steps || 0, at: new Date().toISOString() });
|
|
1140
|
+
save(st, dir);
|
|
1141
|
+
onAlert({ type: 'human', text: `🙋 ts precisa de você:\n${out.needHuman.motivo}\n\nO que fazer:\n${out.needHuman.o_que_fazer}\n\nQuando terminar, rode "ts meta" pra continuar.` });
|
|
1142
|
+
onRoundDone({ checklist: st.checklist, spent: st.creditsSpent, needHuman: true });
|
|
1143
|
+
return st;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// MARCAÇÃO — evidência OBJETIVA primeiro (grátis, à prova de falso-negativo do modelo):
|
|
1147
|
+
// se o item pede um arquivo e a rodada gravou um arquivo com esse nome, PASSA direto.
|
|
1148
|
+
// (Bug real do teste MultiApps: flash-lite bloqueou itens cujos arquivos existiam no disco.)
|
|
1149
|
+
let marks = [];
|
|
1150
|
+
const written = (out.actions || []).filter(a => a.name === 'escrever_arquivo' || a.name === 'editar_arquivo').map(a => String(a.target || ''));
|
|
1151
|
+
const ranOk = (out.actions || []).some(a => a.name === 'executar_comando');
|
|
1152
|
+
const _basename = (p) => String(p).replace(/\\/g, '/').split('/').pop().toLowerCase();
|
|
1153
|
+
const _fileHints = (txt) => (String(txt).match(/[\w.\-]+\.(kt|java|xml|gradle|json|md|txt|kts|properties|pro|png|webp|py|js|ts|html|css|sh)\b/gi) || []).map(s => s.toLowerCase());
|
|
1154
|
+
const itemFiles = _fileHints(item.desc);
|
|
1155
|
+
const wroteForItem = itemFiles.length
|
|
1156
|
+
? itemFiles.some(f => written.some(w => _basename(w) === f))
|
|
1157
|
+
: written.length > 0; // item sem nome de arquivo explícito mas a rodada produziu algo
|
|
1158
|
+
if (wroteForItem || (ranOk && !itemFiles.length)) marks.push(item.id);
|
|
1159
|
+
|
|
1160
|
+
// Item de correção de build: NÃO usa marcador — o PORTÃO DE BUILD é a única
|
|
1161
|
+
// autoridade (recompila de verdade). Marca provisório pra reabrir o gate; se o
|
|
1162
|
+
// build ainda falhar, o gate reabre o item automaticamente.
|
|
1163
|
+
if (item.isBuild) {
|
|
1164
|
+
marks = [item.id];
|
|
1165
|
+
for (const it of st.checklist) if (marks.includes(it.id)) it.passes = true;
|
|
1166
|
+
if (!item.passes && item.attempts >= 99) item.blocked = true; // nunca bloqueia por tentativas — o gate controla
|
|
1167
|
+
st.rounds.push({ item: item.desc, id: item.id, result: String(out.text || '').slice(0, 1500), credits: out.credits || 0, steps: out.steps || 0, at: new Date().toISOString() });
|
|
1168
|
+
save(st, dir);
|
|
1169
|
+
onRoundDone({ marks, credits: out.credits || 0, checklist: st.checklist, spent: st.creditsSpent });
|
|
1170
|
+
continue;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// Marcador IA como REFORÇO: confirma o alvo se a evidência objetiva não pegou, e
|
|
1174
|
+
// credita OUTROS itens do checklist que a rodada também concluiu.
|
|
1175
|
+
try {
|
|
1176
|
+
const evid = written.length ? '\n\nARQUIVOS REALMENTE GRAVADOS nesta rodada (evidência objetiva):\n' + written.map(_basename).join(', ') : '';
|
|
1177
|
+
const mk = await _llmJson({ token, system: MARK_SYS,
|
|
1178
|
+
user: `OBJETIVO:\n${st.goal.slice(0, 600)}\n\nCHECKLIST (contexto):\n${fmtChecklist(st.checklist)}\n\nITEM ALVO: (${item.id}) ${item.desc}\n\nRESULTADO DA RODADA:\n${String(out.text || '').slice(0, 2000)}${evid}` });
|
|
1179
|
+
st.creditsSpent += mk.credits || 0;
|
|
1180
|
+
if (mk.json && mk.json.passou === true) marks.push(item.id);
|
|
1181
|
+
if (Array.isArray(mk.json?.tambem_concluidos)) marks.push(...mk.json.tambem_concluidos.map(String));
|
|
1182
|
+
} catch (_) {}
|
|
1183
|
+
marks = [...new Set(marks)];
|
|
1184
|
+
for (const it of st.checklist) if (marks.includes(it.id)) it.passes = true;
|
|
1185
|
+
// anti-travamento: item que já tentou 2x sem passar é BLOQUEADO (relatado no fim)
|
|
1186
|
+
if (!item.passes && item.attempts >= MAX_ATTEMPTS_PER_ITEM) item.blocked = true;
|
|
1187
|
+
|
|
1188
|
+
st.rounds.push({ item: item.desc, id: item.id, result: String(out.text || '').slice(0, 1500), credits: out.credits || 0, steps: out.steps || 0, at: new Date().toISOString() });
|
|
1189
|
+
save(st, dir);
|
|
1190
|
+
onRoundDone({ marks, credits: out.credits || 0, checklist: st.checklist, spent: st.creditsSpent });
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// Notificação no Telegram do dono (best-effort — sem canal, segue em silêncio)
|
|
1195
|
+
async function notify(token, text) {
|
|
1196
|
+
try { await api('/api/cli/notify', { method: 'POST', token, body: { text }, timeoutMs: 15000 }); } catch (_) {}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
module.exports = { webRunGate, run, load, notify, stateFile, detectBuild, ensureToolchain, runBuild, escalate, designPhase, archPhase, looksVisual, looksComplex, runApp, visualGate, ensureEmulator, _projKind };
|