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.
@@ -0,0 +1,152 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // CMD COMPACTOR — compactação DETERMINÍSTICA de saída de comando (código puro,
3
+ // zero IA, zero custo por chamada). Ideia validada pelo RTK (rtk-ai/rtk):
4
+ // 60-90% da saída de comandos comuns é ruído pro modelo — progresso de npm,
5
+ // testes que PASSARAM, listagens gigantes, linhas repetidas de log. Compacta
6
+ // ANTES de entrar no contexto; a saída COMPLETA vai pra um arquivo temp
7
+ // ("tee") que o modelo pode recuperar com ler_arquivo se precisar do detalhe.
8
+ // Regra de ouro: ERRO nunca é descartado — comando que falhou compacta pouco.
9
+ // ═══════════════════════════════════════════════════════════════════════════
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
13
+
14
+ const MAX_OUT = 3500; // teto de chars da saída compactada (~875 tokens)
15
+ const MAX_FAIL = 6000; // comando que FALHOU merece mais espaço (diagnóstico)
16
+ const TEE_MIN = 2500; // só grava tee se a original passar disto E a economia valer
17
+
18
+ function stripAnsi(s) {
19
+ // códigos de cor/cursor (\x1b[...m etc) e OSC — lixo puro pro modelo
20
+ return String(s).replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, '');
21
+ }
22
+
23
+ // Linhas consecutivas idênticas viram uma só com contador (logs adoram repetir)
24
+ function dedupeLines(s) {
25
+ const lines = String(s).split('\n');
26
+ const out = [];
27
+ for (let i = 0; i < lines.length; i++) {
28
+ let j = i;
29
+ while (j + 1 < lines.length && lines[j + 1] === lines[i] && lines[i].trim()) j++;
30
+ const reps = j - i + 1;
31
+ out.push(reps > 2 ? lines[i] + ` [repetida ${reps}x]` : lines[i]);
32
+ if (reps === 2) out.push(lines[i]);
33
+ i = j;
34
+ }
35
+ return out.join('\n');
36
+ }
37
+
38
+ function headTail(s, max) {
39
+ if (s.length <= max) return s;
40
+ const head = Math.floor(max * 0.6);
41
+ const tail = max - head - 90;
42
+ return s.slice(0, head)
43
+ + `\n… [${s.length - head - tail} chars omitidos — saída completa no arquivo tee, se indicado] …\n`
44
+ + s.slice(s.length - tail);
45
+ }
46
+
47
+ function capLines(s, n, rotulo) {
48
+ const lines = s.split('\n');
49
+ if (lines.length <= n) return s;
50
+ return lines.slice(0, n).join('\n') + `\n… +${lines.length - n} ${rotulo || 'linhas'} (total ${lines.length})`;
51
+ }
52
+
53
+ function lastLines(s, n) {
54
+ const lines = s.split('\n');
55
+ if (lines.length <= n) return s;
56
+ return `[…${lines.length - n} linhas anteriores omitidas — em log, o fim é o que importa]\n` + lines.slice(-n).join('\n');
57
+ }
58
+
59
+ // ── Filtros por tipo de comando (cada um recebe saída JÁ limpa de ANSI/dupes) ──
60
+ const FILTERS = [
61
+ {
62
+ // npm/yarn/pnpm install: só interessa o desfecho e os erros, nunca o progresso
63
+ re: /\b(npm|pnpm|yarn)\b[^&|;]*\b(install|ci|add|update|audit fix)\b/,
64
+ fn(s) {
65
+ const keep = s.split('\n').filter(l =>
66
+ !/^\s*(npm (WARN|notice)|warning |info |Progress:|\[[\d\/#= .]+\]|⸨|added \d+ packages? in|fetch)/i.test(l) || /ERR|error/i.test(l));
67
+ const tailBlock = s.split('\n').slice(-6).join('\n'); // resumo final do npm
68
+ return capLines(keep.join('\n'), 40, 'linhas de instalação') + '\n--- fim ---\n' + tailBlock;
69
+ },
70
+ },
71
+ {
72
+ // pip install: idem — "Requirement already satisfied" em massa é o pior ofensor
73
+ re: /\bpip3?\b[^&|;]*\binstall\b/,
74
+ fn(s) {
75
+ const keep = s.split('\n').filter(l =>
76
+ !/^\s*(Requirement already satisfied|Downloading|Using cached|Collecting |Preparing |Installing collected|Attempting |\s*\|)/i.test(l));
77
+ const ok = s.match(/Successfully installed[^\n]*/);
78
+ return capLines(keep.join('\n'), 30, 'linhas pip') + (ok ? '\n' + ok[0] : '');
79
+ },
80
+ },
81
+ {
82
+ // Runners de teste: falhas + resumo; os "passed" individuais são ruído
83
+ re: /\b(pytest|jest|vitest|mocha|cargo test|go test|phpunit)\b|npm (run )?test\b/,
84
+ fn(s) {
85
+ const lines = s.split('\n');
86
+ const fails = lines.filter(l => /FAIL|ERROR|✕|✗|not ok|AssertionError|Traceback|panic|Exception/i.test(l));
87
+ const resumo = lines.slice(-12).join('\n');
88
+ if (!fails.length) return '[todos os testes individuais omitidos — nenhum FALHOU]\n' + resumo;
89
+ return capLines(fails.join('\n'), 60, 'falhas') + '\n--- resumo ---\n' + resumo;
90
+ },
91
+ },
92
+ {
93
+ // git diff grande: diffstat + começo (o modelo pede o arquivo específico se precisar)
94
+ re: /\bgit\b[^&|;]*\bdiff\b/,
95
+ fn(s) {
96
+ if (s.split('\n').length <= 150) return s;
97
+ const files = [...s.matchAll(/^diff --git a\/(.+?) b\//gm)].map(m => m[1]);
98
+ const stat = files.length ? `[diff em ${files.length} arquivo(s): ${files.slice(0, 20).join(', ')}${files.length > 20 ? '…' : ''}]\n` : '';
99
+ return stat + capLines(s, 100, 'linhas de diff');
100
+ },
101
+ },
102
+ { re: /\bgit\b[^&|;]*\b(log|status|branch|show)\b/, fn: (s) => capLines(s, 40, 'linhas git') },
103
+ { re: /\b(ls|dir|find|tree|locate)\b|Get-ChildItem/i, fn: (s) => capLines(s, 80, 'entradas') },
104
+ { re: /\b(grep|rg|findstr|Select-String)\b/i, fn: (s) => capLines(s, 80, 'ocorrências') },
105
+ { re: /\b(journalctl|tail|docker logs|pm2 logs)\b|\.log\b/, fn: (s) => lastLines(s, 80) },
106
+ ];
107
+
108
+ // API principal. opts: { codigo (exit code), teeDir }
109
+ function compact(comando, stdout, opts = {}) {
110
+ const original = String(stdout || '');
111
+ if (!original) return { out: original };
112
+ const failed = typeof opts.codigo === 'number' && opts.codigo !== 0;
113
+ const max = failed ? MAX_FAIL : MAX_OUT;
114
+
115
+ let s = dedupeLines(stripAnsi(original));
116
+ // Filtro específico só quando o comando DEU CERTO — em falha, o detalhe é diagnóstico
117
+ if (!failed) {
118
+ const cmd = String(comando || '');
119
+ for (const f of FILTERS) { if (f.re.test(cmd)) { try { s = f.fn(s); } catch (_) {} break; } }
120
+ }
121
+ s = headTail(s, max);
122
+
123
+ const saved = original.length - s.length;
124
+ const r = { out: s };
125
+ // "tee": guarda a íntegra quando cortamos algo relevante — recuperável, nunca perdido.
126
+ // noTee: comando rodou em máquina REMOTA (ssh) — um tee local seria ilegível pra sessão;
127
+ // o marcador de omissão já orienta o modelo a re-rodar com grep/tail focado.
128
+ if (!opts.noTee && saved > 400 && original.length > TEE_MIN) {
129
+ try {
130
+ const dir = opts.teeDir || path.join(os.tmpdir(), 'ts-cmd-out');
131
+ fs.mkdirSync(dir, { recursive: true });
132
+ const file = path.join(dir, 'out-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6) + '.txt');
133
+ fs.writeFileSync(file, original);
134
+ r.tee = file;
135
+ r.note = `Saída compactada (economia de ~${Math.round(saved / 4)} tokens). Íntegra em: ${file} (use ler_arquivo SÓ se precisar do detalhe omitido).`;
136
+ _gcTee(dir);
137
+ } catch (_) { /* tee é conveniência — falhou, segue sem */ }
138
+ }
139
+ return r;
140
+ }
141
+
142
+ // Mantém o diretório tee enxuto (máx ~40 arquivos / 24h) — é cache, não histórico
143
+ function _gcTee(dir) {
144
+ try {
145
+ const now = Date.now();
146
+ const files = fs.readdirSync(dir).map(f => path.join(dir, f))
147
+ .map(p => ({ p, t: fs.statSync(p).mtimeMs })).sort((a, b) => b.t - a.t);
148
+ files.forEach((f, i) => { if (i >= 40 || now - f.t > 24 * 3600 * 1000) { try { fs.unlinkSync(f.p); } catch (_) {} } });
149
+ } catch (_) {}
150
+ }
151
+
152
+ module.exports = { compact, stripAnsi, dedupeLines };
package/lib/config.js ADDED
@@ -0,0 +1,20 @@
1
+ // Config do CLI em ~/.ts/config.json — token, idioma, conversa ativa.
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const path = require('path');
5
+
6
+ const DIR = path.join(os.homedir(), '.ts');
7
+ const FILE = path.join(DIR, 'config.json');
8
+
9
+ function load() {
10
+ try { return JSON.parse(fs.readFileSync(FILE, 'utf8')) || {}; } catch (_) { return {}; }
11
+ }
12
+ function save(patch) {
13
+ const cfg = Object.assign(load(), patch);
14
+ for (const k of Object.keys(cfg)) if (cfg[k] === undefined || cfg[k] === null) delete cfg[k];
15
+ fs.mkdirSync(DIR, { recursive: true });
16
+ fs.writeFileSync(FILE, JSON.stringify(cfg, null, 2));
17
+ try { fs.chmodSync(FILE, 0o600); } catch (_) { /* Windows: sem chmod */ }
18
+ return cfg;
19
+ }
20
+ module.exports = { load, save, FILE };
package/lib/eval.js ADDED
@@ -0,0 +1,243 @@
1
+ // lib/eval.js — PIPELINE DE AVALIAÇÃO do agente ts (padrão evals da Anthropic).
2
+ // Roda o agente local em cada CASO de uma suíte, num SANDBOX temporário isolado,
3
+ // coleta EVIDÊNCIA determinística (arquivos criados/alterados + resposta final)
4
+ // e um LLM-JUDGE dá veredito {pass, score, reason}. Relatório com taxa de
5
+ // aprovação, custo (Smart Credits) e tempo.
6
+ //
7
+ // É ferramenta de DEV/CI: custa tokens de propósito (roda o agente de verdade),
8
+ // então NUNCA roda sozinha — só quando o usuário chama `ts eval`.
9
+ //
10
+ // Formato da suíte (JSON): { "name": "...", "cases": [ {
11
+ // "id": "criar-readme", // rótulo (default caso-N)
12
+ // "task": "crie um README.md ...", // tarefa dada ao agente
13
+ // "expect": "existe README.md com ...", // critério de sucesso (o juiz avalia contra isto)
14
+ // "files": { "app.js": "console.log(1)" }, // arquivos criados no sandbox ANTES da run
15
+ // "readonly": false // roda em modo --ler (não deve alterar nada)
16
+ // } ] }
17
+ // Também aceita um ARRAY direto de casos no lugar do objeto.
18
+ const os = require('os');
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const { api } = require('./api');
22
+ const agent = require('./agent');
23
+
24
+ // modelo default do JUIZ: barato e suficiente pra avaliar critério objetivo.
25
+ const DEFAULT_JUDGE = 'deepseek-v4-flash';
26
+
27
+ // ── suíte ────────────────────────────────────────────────────────────────────
28
+ function loadSuite(p) {
29
+ const raw = fs.readFileSync(p, 'utf8');
30
+ let j; try { j = JSON.parse(raw); } catch (e) { throw new Error('suíte inválida (JSON): ' + e.message); }
31
+ const rawCases = Array.isArray(j) ? j : (j && Array.isArray(j.cases) ? j.cases : null);
32
+ if (!rawCases) throw new Error('suíte precisa de um array "cases" (ou ser um array de casos)');
33
+ const cases = normCases(rawCases);
34
+ if (!cases.length) throw new Error('nenhum caso válido (cada caso precisa de "task" e "expect")');
35
+ return { name: String((j && j.name) || path.basename(p)), cases };
36
+ }
37
+
38
+ function normCases(arr) {
39
+ return arr.map((c, i) => ({
40
+ id: String((c && c.id) || ('caso-' + (i + 1))).replace(/[^\w.-]/g, '_').slice(0, 60),
41
+ task: String((c && c.task) || '').trim(),
42
+ expect: String((c && c.expect) || '').trim(),
43
+ files: (c && c.files && typeof c.files === 'object' && !Array.isArray(c.files)) ? c.files : {},
44
+ readonly: !!(c && c.readonly),
45
+ })).filter(c => c.task && c.expect);
46
+ }
47
+
48
+ // suíte embutida pro `--smoke` (auto-teste rápido; 3 casos baratos e determinísticos)
49
+ function smokeSuite() {
50
+ return { name: 'smoke', cases: normCases([
51
+ { id: 'criar-arquivo', task: "crie um arquivo saudacao.txt cujo conteúdo seja exatamente: oi mundo", expect: "existe um arquivo saudacao.txt e seu conteúdo é exatamente 'oi mundo' (sem outras linhas)" },
52
+ { id: 'editar-json', task: "no arquivo config.json, troque o valor de \"versao\" de 1 para 2, preservando o resto", expect: 'config.json continua um JSON válido com "versao": 2 e mantém o campo "nome"', files: { 'config.json': '{\n "nome": "app",\n "versao": 1\n}\n' } },
53
+ { id: 'ler-responder', task: "quantas funções (linhas que começam com 'function ') existem em util.js? responda só o número.", expect: 'a resposta final informa 2 funções', files: { 'util.js': 'function a(){}\nconst x=1;\nfunction b(){}\n' }, readonly: true },
54
+ ]) };
55
+ }
56
+
57
+ // exemplo pra `ts eval --exemplo` (bootstrap de suíte pro usuário)
58
+ const EXAMPLE = {
59
+ name: 'minha-suite',
60
+ cases: [
61
+ { id: 'criar-readme', task: 'crie um README.md descrevendo um app de lista de tarefas', expect: 'existe README.md não-vazio mencionando "tarefas" ou "todo"' },
62
+ { id: 'corrigir-bug', task: 'em soma.js a função soma retorna a-b; corrija para retornar a+b', expect: 'soma.js define soma(a,b) retornando a+b', files: { 'soma.js': 'function soma(a,b){ return a-b; }\nmodule.exports = soma;\n' } },
63
+ ],
64
+ };
65
+
66
+ // ── sandbox / evidência ──────────────────────────────────────────────────────
67
+ function _snap(dir) {
68
+ const out = {};
69
+ const walk = (d, rel) => {
70
+ let ents = []; try { ents = fs.readdirSync(d, { withFileTypes: true }); } catch (_) { return; }
71
+ for (const e of ents) {
72
+ if (e.name === '.git' || e.name === 'node_modules') continue;
73
+ const full = path.join(d, e.name), r = rel ? rel + '/' + e.name : e.name;
74
+ if (e.isDirectory()) walk(full, r);
75
+ else {
76
+ try { const st = fs.statSync(full); out[r] = st.size > 200000 ? `<binário ${st.size}b>` : fs.readFileSync(full, 'utf8'); }
77
+ catch (_) { out[r] = '<ilegível>'; }
78
+ }
79
+ }
80
+ };
81
+ walk(dir, '');
82
+ return out;
83
+ }
84
+
85
+ // diff de dois snapshots → { criados:[], alterados:[], removidos:[] } com conteúdo dos 2 primeiros
86
+ function _diff(before, after) {
87
+ const criados = [], alterados = [], removidos = [];
88
+ for (const k of Object.keys(after)) {
89
+ if (!(k in before)) criados.push({ path: k, content: after[k] });
90
+ else if (before[k] !== after[k]) alterados.push({ path: k, content: after[k] });
91
+ }
92
+ for (const k of Object.keys(before)) if (!(k in after)) removidos.push({ path: k });
93
+ return { criados, alterados, removidos };
94
+ }
95
+
96
+ // ── juiz ─────────────────────────────────────────────────────────────────────
97
+ function _extractJson(s) {
98
+ const t = String(s || '').replace(/```(?:json)?/gi, '').trim();
99
+ if (!t) return null;
100
+ // 1) o texto inteiro já é JSON? (caminho feliz)
101
+ try { return JSON.parse(t); } catch (_) {}
102
+ // 2) varre o 1º objeto {...} BALANCEADO que parseia — respeita strings/escapes, então
103
+ // prosa com chaves antes/depois (ex.: 'Resultado {ok}: {"pass":true}') não quebra mais.
104
+ for (let i = 0; i < t.length; i++) {
105
+ if (t[i] !== '{') continue;
106
+ let depth = 0, inStr = false, esc = false;
107
+ for (let j = i; j < t.length; j++) {
108
+ const ch = t[j];
109
+ if (inStr) { if (esc) esc = false; else if (ch === '\\') esc = true; else if (ch === '"') inStr = false; continue; }
110
+ if (ch === '"') inStr = true;
111
+ else if (ch === '{') depth++;
112
+ else if (ch === '}') { if (--depth === 0) { try { return JSON.parse(t.slice(i, j + 1)); } catch (_) {} break; } }
113
+ }
114
+ }
115
+ return null;
116
+ }
117
+
118
+ async function judge({ key, baseUrl, model, caseObj, evidence }) {
119
+ const changed = [...evidence.diff.criados, ...evidence.diff.alterados]
120
+ .map(f => `--- ${f.path} ---\n${String(f.content).slice(0, 4000)}`).join('\n\n') || '(nenhum arquivo criado/alterado)';
121
+ const removed = evidence.diff.removidos.map(f => f.path).join(', ') || '(nenhum)';
122
+ const sys = 'Você é um JUIZ rigoroso e imparcial que avalia se um agente de código CUMPRIU um critério de sucesso. '
123
+ + 'Baseie-se SÓ na evidência (a resposta final do agente e os arquivos do sandbox). Não dê o benefício da dúvida: '
124
+ + 'pass=true SOMENTE se o critério foi objetivamente satisfeito pela evidência. Responda APENAS com JSON, sem texto fora dele: '
125
+ + '{"pass": true|false, "score": 0-100, "reason": "uma frase curta e específica"}.';
126
+ const user = [
127
+ 'TAREFA dada ao agente:\n' + caseObj.task,
128
+ '\nCRITÉRIO DE SUCESSO (avalie contra isto):\n' + caseObj.expect,
129
+ caseObj.readonly ? '\n(MODO SÓ-LEITURA: o agente NÃO deveria ter criado/alterado nenhum arquivo.)' : '',
130
+ '\n=== EVIDÊNCIA ===',
131
+ 'Erro fatal na execução do agente? ' + (evidence.error ? ('SIM — ' + evidence.error) : 'não'),
132
+ '\nResposta final do agente:\n' + (String(evidence.text || '').slice(0, 4000) || '(vazia)'),
133
+ '\nArquivos criados/alterados no sandbox:\n' + changed,
134
+ '\nArquivos removidos: ' + removed,
135
+ ].join('\n');
136
+ const r = await agent.llm({ baseUrl, key, model, noTools: true, signalMs: 60000,
137
+ messages: [{ role: 'system', content: sys }, { role: 'user', content: user }] });
138
+ const u = r.usage || {};
139
+ const j = _extractJson(r.msg && r.msg.content) || {};
140
+ return {
141
+ pass: j.pass === true,
142
+ score: Math.max(0, Math.min(100, Number(j.score) || (j.pass === true ? 100 : 0))),
143
+ reason: String(j.reason || (j.pass === true ? 'critério cumprido' : 'critério não cumprido')).slice(0, 240),
144
+ _tin: u.prompt_tokens || 0, _tout: u.completion_tokens || 0,
145
+ _raw: !Object.keys(j).length ? String(r.msg && r.msg.content || '').slice(0, 200) : null,
146
+ };
147
+ }
148
+
149
+ // ── runner ───────────────────────────────────────────────────────────────────
150
+ // opts: { token, lang, model (executor), judgeModel, onCase({i,total,id,phase}) }
151
+ // Roda os casos EM SÉRIE (sandbox isolado por caso; evita colisão e rate-limit).
152
+ async function runSuite(suite, opts = {}) {
153
+ const { token, lang = 'pt', model = null, judgeModel = DEFAULT_JUDGE } = opts;
154
+ const onCase = opts.onCase || (() => {});
155
+ // chave sk-hub (mesma do agente) — reusada pra chamar o juiz direto no gateway.
156
+ const k = await api('/api/ai/key?feature=cli_agent', { token, timeoutMs: 20000 });
157
+ if (!k || !k.key) { const e = new Error('sem chave de IA'); e.code = 'ai_key'; throw e; }
158
+
159
+ const results = [];
160
+ const total = suite.cases.length;
161
+ for (let i = 0; i < total; i++) {
162
+ const c = suite.cases[i];
163
+ let sandbox = null;
164
+ let evidence = { text: '', error: null, diff: { criados: [], alterados: [], removidos: [] } };
165
+ let runCredits = 0, runSteps = 0, verdict = null, judgeTin = 0, judgeTout = 0;
166
+ const t0 = Date.now();
167
+ try {
168
+ // sandbox DENTRO do try: se mkdtempSync falhar (EMFILE/disco cheio/permissão) o caso
169
+ // REPROVA sozinho, não derruba a suíte inteira.
170
+ sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'ts-eval-'));
171
+ // prepara arquivos de fixture. GUARDA de path traversal: um caminho com "../"
172
+ // que ESCAPE o sandbox é ignorado (uma suíte compartilhada não deve escrever fora).
173
+ for (const [rel, content] of Object.entries(c.files)) {
174
+ const fp = path.resolve(sandbox, rel);
175
+ if (fp !== sandbox && !fp.startsWith(sandbox + path.sep)) continue;
176
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
177
+ fs.writeFileSync(fp, String(content));
178
+ }
179
+ const before = _snap(sandbox);
180
+ onCase({ i, total, id: c.id, phase: 'run' });
181
+ // roda o agente CONFINADO no sandbox (confineDir), sem hooks de sessão, --yes
182
+ // (destrutivo é recusado sozinho — eval nunca deve pedir aprovação humana).
183
+ let out;
184
+ try {
185
+ out = await agent.run(c.task, {
186
+ token, lang, yes: true, model,
187
+ cwd: sandbox, confineDir: sandbox, readOnly: c.readonly,
188
+ skipSessionStart: true,
189
+ });
190
+ } catch (e) {
191
+ if (e && (e.code === 'no_credits' || e.status === 402)) throw e; // teto → aborta a suíte
192
+ evidence.error = String((e && e.message) || e).slice(0, 300);
193
+ out = null;
194
+ }
195
+ if (out) { evidence.text = out.text || ''; runCredits = out.credits || 0; runSteps = out.steps || 0; }
196
+ const after = _snap(sandbox);
197
+ evidence.diff = _diff(before, after);
198
+ // julga (mesmo com erro fatal — o juiz vê o erro e reprova com motivo)
199
+ onCase({ i, total, id: c.id, phase: 'judge' });
200
+ try {
201
+ verdict = await judge({ key: k.key, baseUrl: k.baseUrl, model: judgeModel, caseObj: c, evidence });
202
+ judgeTin = verdict._tin || 0; judgeTout = verdict._tout || 0;
203
+ } catch (e) {
204
+ if (e && (e.code === 'no_credits' || e.status === 402)) throw e;
205
+ verdict = { pass: false, score: 0, reason: 'juiz falhou: ' + String((e && e.message) || e).slice(0, 120) };
206
+ }
207
+ } catch (e) {
208
+ // teto de IA → aborta a suíte inteira (o finally limpa o sandbox antes de propagar).
209
+ if (e && (e.code === 'no_credits' || e.status === 402)) throw e;
210
+ // qualquer outra falha (mkdtemp, fixture inválida, snapshot, etc.) → REPROVA SÓ ESTE
211
+ // CASO, nunca derruba a suíte toda.
212
+ evidence.error = evidence.error || String((e && e.message) || e).slice(0, 300);
213
+ verdict = verdict || { pass: false, score: 0, reason: 'caso falhou: ' + String((e && e.message) || e).slice(0, 120) };
214
+ } finally {
215
+ // limpa o sandbox (best-effort; pode ser null se o mkdtemp falhou)
216
+ if (sandbox) { try { fs.rmSync(sandbox, { recursive: true, force: true }); } catch (_) {} }
217
+ }
218
+ if (!verdict) verdict = { pass: false, score: 0, reason: 'sem veredito' };
219
+ results.push({
220
+ id: c.id, pass: verdict.pass, score: verdict.score, reason: verdict.reason,
221
+ error: evidence.error, steps: runSteps, credits: runCredits,
222
+ changed: evidence.diff.criados.length + evidence.diff.alterados.length,
223
+ judgeTokens: judgeTin + judgeTout, ms: Date.now() - t0, judgeRaw: verdict._raw || null,
224
+ });
225
+ onCase({ i, total, id: c.id, phase: 'done', pass: verdict.pass, score: verdict.score });
226
+ }
227
+
228
+ const passed = results.filter(r => r.pass).length;
229
+ const summary = {
230
+ name: suite.name, total, passed, failed: total - passed,
231
+ passRate: total ? Math.round((100 * passed) / total) : 0,
232
+ avgScore: total ? Math.round(results.reduce((a, r) => a + r.score, 0) / total) : 0,
233
+ credits: results.reduce((a, r) => a + (r.credits || 0), 0),
234
+ // tokens do JUIZ (não são cobrados como Smart Credits, mas consomem o teto sk-hub —
235
+ // exibidos à parte pra o custo do relatório ser honesto).
236
+ judgeTokens: results.reduce((a, r) => a + (r.judgeTokens || 0), 0),
237
+ ms: results.reduce((a, r) => a + (r.ms || 0), 0),
238
+ };
239
+ return { summary, results };
240
+ }
241
+
242
+ module.exports = { loadSuite, normCases, runSuite, judge, smokeSuite, EXAMPLE,
243
+ _test: { _diff, _snap, _extractJson, normCases } };
package/lib/hooks.js ADDED
@@ -0,0 +1,72 @@
1
+ // Hooks de ciclo de vida do agente ts (padrão Claude Code) — DETERMINÍSTICOS, rodam
2
+ // independente do que o modelo decide. Generaliza o gate destrutivo hardcoded.
3
+ //
4
+ // SEGURANÇA: hooks rodam comando de shell. Por isso SÓ carregamos o arquivo GLOBAL
5
+ // ~/.ts/hooks.json (config da própria máquina do usuário = confiável). NÃO lemos
6
+ // .ts-hooks.json de projeto — um repo clonado poderia embutir um hook malicioso (RCE).
7
+ //
8
+ // Formato ~/.ts/hooks.json:
9
+ // {
10
+ // "PreToolUse": [{ "match": "escrever_arquivo|editar_arquivo", "command": "meu-script.sh" }],
11
+ // "PostToolUse": [{ "match": "editar_arquivo", "command": "prettier --write $TS_FILE || true" }],
12
+ // "SessionStart":[{ "command": "echo contexto extra" }]
13
+ // }
14
+ // Cada hook recebe o PAYLOAD como JSON no stdin. Também expõe env: TS_TOOL, TS_FILE, TS_EVENT.
15
+ // PreToolUse: exit != 0 ⇒ BLOQUEIA (stderr = motivo); ou stdout JSON {"decision":"block","reason":"..."}.
16
+ // PostToolUse/SessionStart: stdout vira FEEDBACK/contexto pro modelo (não bloqueia).
17
+ const fs = require('fs');
18
+ const os = require('os');
19
+ const path = require('path');
20
+ const { execSync } = require('child_process');
21
+
22
+ const FILE = path.join(os.homedir(), '.ts', 'hooks.json');
23
+ const EVENTS = ['PreToolUse', 'PostToolUse', 'SessionStart', 'Stop'];
24
+
25
+ function load() {
26
+ const out = { PreToolUse: [], PostToolUse: [], SessionStart: [], Stop: [] };
27
+ try {
28
+ const cfg = JSON.parse(fs.readFileSync(FILE, 'utf8'));
29
+ for (const ev of EVENTS) if (Array.isArray(cfg[ev])) out[ev] = cfg[ev].filter(h => h && typeof h.command === 'string');
30
+ } catch (_) {}
31
+ out._any = EVENTS.some(ev => out[ev].length);
32
+ return out;
33
+ }
34
+
35
+ // Roda os hooks de um evento que casam com o toolName. NUNCA lança (best-effort).
36
+ // Retorna { block, reason, feedback }.
37
+ function run(hooks, event, payload = {}) {
38
+ const list = (hooks && hooks[event]) || [];
39
+ if (!list.length) return { block: false, feedback: '' };
40
+ // TS_FILE vem do MODELO (caminho que ele passou). Se tiver metacaractere de shell, NÃO exporta
41
+ // (evita command injection quando o hook do usuário referencia %TS_FILE%/$TS_FILE sem aspas).
42
+ const _raw = (payload.input && (payload.input.caminho || payload.input.arquivo)) || '';
43
+ const _tsFile = /[&|<>;$%()`"'\r\n*]/.test(String(_raw)) ? '' : String(_raw);
44
+ let feedback = '';
45
+ for (const h of list) {
46
+ // regex de match inválida → PULA o hook (não roda em tudo por engano — fail-safe, não fail-open)
47
+ if (h.match) { let re; try { re = new RegExp(h.match); } catch (_) { continue; } if (payload.tool && !re.test(payload.tool)) continue; }
48
+ const _t = Number(h.timeout);
49
+ let out = '';
50
+ try {
51
+ out = execSync(h.command, {
52
+ input: JSON.stringify(payload),
53
+ timeout: Math.min(_t > 0 ? _t : 15000, 60000),
54
+ encoding: 'utf8',
55
+ stdio: ['pipe', 'pipe', 'pipe'],
56
+ env: Object.assign({}, process.env, { TS_EVENT: event, TS_TOOL: payload.tool || '', TS_FILE: _tsFile }),
57
+ });
58
+ } catch (e) {
59
+ const reason = String((e && (e.stderr || e.stdout || e.message)) || '').slice(0, 600).trim();
60
+ if (event === 'PreToolUse') return { block: true, reason: reason || `hook bloqueou "${payload.tool}"` };
61
+ if (reason) feedback += (feedback ? '\n' : '') + reason;
62
+ continue;
63
+ }
64
+ let j = null; try { j = JSON.parse(out); } catch (_) {}
65
+ if (event === 'PreToolUse' && j && j.decision === 'block') return { block: true, reason: String(j.reason || '').slice(0, 600) || `hook bloqueou "${payload.tool}"` };
66
+ const fb = (j && (j.feedback || j.reason)) || String(out || '').trim();
67
+ if (fb) feedback += (feedback ? '\n' : '') + String(fb).slice(0, 600);
68
+ }
69
+ return { block: false, feedback };
70
+ }
71
+
72
+ module.exports = { load, run, FILE };