@fefsbenson/jarvis 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js CHANGED
@@ -12,7 +12,9 @@
12
12
  *
13
13
  * JARVIS · PRAGMA · by Felipe Brandão
14
14
  */
15
- import { createWriteStream, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
15
+ import { createWriteStream, existsSync, mkdirSync, readdirSync, statSync,
16
+ readFileSync, writeFileSync, copyFileSync, rmSync } from 'node:fs';
17
+ import { createHash } from 'node:crypto';
16
18
  import { spawn, spawnSync } from 'node:child_process';
17
19
  import { createInterface } from 'node:readline';
18
20
  import { tmpdir } from 'node:os';
@@ -323,11 +325,452 @@ async function instalar(args) {
323
325
  return r.status ?? 0;
324
326
  }
325
327
 
328
+ // ══════════════════════════════════════════════════════════════════
329
+ // UPDATE — traz a versão nova sem apagar o que o dono fez
330
+ // ══════════════════════════════════════════════════════════════════
331
+
332
+ /**
333
+ * O manifesto que a instalação grava: hash de cada arquivo COMO SAIU do
334
+ * pacote. É ele que torna a atualização possível sem escolher entre duas
335
+ * coisas ruins — apagar o trabalho do dono, ou nunca entregar correção.
336
+ *
337
+ * Sem manifesto (instalação anterior à v1.1.7) o update ainda funciona,
338
+ * mas não sabe o que foi alterado: aí ele preserva por precaução tudo que
339
+ * difere do pacote novo, e diz isso na tela.
340
+ */
341
+ function lerManifesto(raiz) {
342
+ const f = join(raiz, '.claude', 'jarvis', 'instalacao-original.json');
343
+ if (!existsSync(f)) return null;
344
+ try {
345
+ const d = JSON.parse(readFileSync(f, 'utf8'));
346
+ return (d && d.arquivos) ? d : null;
347
+ } catch { return null; }
348
+ }
349
+
350
+ function hashDe(caminho) {
351
+ try {
352
+ return createHash('sha256').update(readFileSync(caminho)).digest('hex');
353
+ } catch { return null; }
354
+ }
355
+
356
+ /**
357
+ * Pastas que NUNCA se sobrescreve, porque são do dono por natureza:
358
+ * credenciais, memória do sistema, o trabalho dele, estado e registros.
359
+ * Isto não depende do manifesto — é contrato, e vale mesmo sem ele.
360
+ */
361
+ const INTOCAVEIS = [
362
+ '.env', '.venv', 'node_modules', '.git',
363
+ 'jarvis.config.yaml',
364
+ '.claude/settings.local.json',
365
+ '.claude/jarvis/', // memória, estado, ponteiros
366
+ '.claude/sessions/', // histórico de sessões
367
+ '.claude/mission-control/', // estado de missão e índices
368
+ '.claude/trash/', // o que o dono mandou para a lixeira
369
+ 'workspace/', // o negócio do dono
370
+ 'knowledge/business/', 'knowledge/personal/',
371
+ 'knowledge/external/inbox/', // o material que o DONO ingere
372
+ 'logs/', '.data/', 'artifacts/', 'processing/',
373
+ 'outputs/', 'research/', // L3 no directory-contract: produção do dono
374
+ ];
375
+
376
+ /**
377
+ * Sufixos protegidos DENTRO de área do sistema.
378
+ *
379
+ * `agents/` é do pacote — AGENT.md, DNA-CONFIG.yaml e ACTIVATION.yaml vêm daqui
380
+ * e devem receber correção. Mas MEMORY.md e SOUL.md são a memória que o agente
381
+ * ACUMULA conversando com o dono: sobrescrevê-los apaga meses de uso.
382
+ *
383
+ * Medido no pacote 1.1.24: 91 arquivos nessa condição. Sem esta lista, um
384
+ * `npx jarvis update` os zeraria em silêncio — o dono só descobriria quando o
385
+ * agente esquecesse tudo o que sabia sobre ele.
386
+ */
387
+ const SUFIXOS_DO_DONO = ['/MEMORY.md', '/SOUL.md'];
388
+
389
+ /**
390
+ * Exceções DENTRO de pasta protegida: arquivos que o sistema mantém e o dono
391
+ * não escreve. Sem esta lista, `.claude/jarvis/` inteiro fica congelado — e o
392
+ * manifesto nunca se renova, então uma instalação antiga seguiria para sempre
393
+ * sem saber o que o dono alterou.
394
+ */
395
+ const SEMPRE_ATUALIZA = ['VERSION', 'MANIFESTO-PACOTE.json', 'LICENSE'];
396
+
397
+ const EXCECOES = [
398
+ '.claude/jarvis/instalacao-original.json',
399
+ ];
400
+
401
+ function ehIntocavel(rel) {
402
+ const n = rel.split('\\').join('/');
403
+ if (EXCECOES.includes(n)) return false;
404
+ if (SUFIXOS_DO_DONO.some(s => n.endsWith(s))) return true;
405
+ return INTOCAVEIS.some(i => i.endsWith('/') ? n.startsWith(i) : n === i);
406
+ }
407
+
408
+ function listar(dir, base = dir, saida = []) {
409
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
410
+ if (e.name === 'node_modules' || e.name === '.git' ||
411
+ e.name === '__pycache__' || e.name === '.venv') continue;
412
+ const cheio = join(dir, e.name);
413
+ const rel = cheio.slice(base.length + 1).split('\\').join('/');
414
+ if (e.isDirectory()) listar(cheio, base, saida);
415
+ else saida.push(rel);
416
+ }
417
+ return saida;
418
+ }
419
+
420
+ /**
421
+ * Compara a instalação atual com o pacote novo e classifica cada arquivo.
422
+ *
423
+ * Os quatro casos, e por que cada um se resolve assim:
424
+ *
425
+ * novo — não existe aqui: entra.
426
+ * igual — o dono não mexeu E o pacote não mudou: nada a fazer.
427
+ * atualiza — o dono não mexeu e o pacote mudou: entra (é a correção).
428
+ * preserva — o dono MEXEU: fica o dele. É o ponto todo do manifesto;
429
+ * sem ele, "atualizar" seria apagar o trabalho de alguém.
430
+ */
431
+ /**
432
+ * O "motor": o que VEM DO PACOTE, por oposição ao que o dono produz.
433
+ *
434
+ * Numa instalação sem manifesto é a única forma de distinguir a correção
435
+ * chegando de uma edição do dono — e a chance de alguém ter editado um
436
+ * arquivo do sistema à mão é muito menor que a de ficar sem o conserto.
437
+ *
438
+ * A fronteira já foi estreita demais: cobria só hooks, scripts e core, e
439
+ * medido no pacote 1.1.24 isso deixava **4.642 arquivos sem correção** —
440
+ * 2.118 skills, 1.897 de conhecimento curado, 266 agentes, 73 rules. Uma
441
+ * instalação antiga atualizava e recebia quase nada: um update que parece
442
+ * seguro por não fazer nada.
443
+ *
444
+ * O que torna a fronteira larga SEGURA é o backup: tudo que entra por aqui
445
+ * e difere ganha `.antigo` ao lado (ver `aplicar`). Nada se perde sem volta.
446
+ *
447
+ * O que o dono produz nunca chega aqui — `ehIntocavel` roda antes e já tirou
448
+ * workspace, buckets business/personal, o inbox dele, outputs, research,
449
+ * estado, sessões, credenciais, e MEMORY.md/SOUL.md dos agentes.
450
+ */
451
+ function ehDoMotor(rel) {
452
+ const n = rel.split('\\').join('/');
453
+ return n.startsWith('.claude/hooks/') ||
454
+ n.startsWith('.claude/scripts/') ||
455
+ n.startsWith('.claude/skills/') || // 2.118 arquivos
456
+ n.startsWith('.claude/rules/') || // 73
457
+ n.startsWith('.claude/commands/') ||
458
+ n.startsWith('.claude/get-shit-done/') ||
459
+ n.startsWith('core/') ||
460
+ n.startsWith('bin/') ||
461
+ n.startsWith('reference/') ||
462
+ n.startsWith('system/') ||
463
+ // conhecimento CURADO (L2): dna, dossiers, playbooks, sources, mmos.
464
+ // O inbox do dono já saiu antes, por ehIntocavel.
465
+ n.startsWith('knowledge/external/') ||
466
+ // definição do agente vem do pacote; MEMORY.md e SOUL.md dele não
467
+ // chegam aqui — ehIntocavel os barra antes.
468
+ n.startsWith('agents/') ||
469
+ n === 'install.py' ||
470
+ n === 'requirements.txt' ||
471
+ n === 'requirements-opcional.txt';
472
+ }
473
+
474
+ function planejar(raizAtual, raizNova, manifesto) {
475
+ const orig = manifesto ? manifesto.arquivos : null;
476
+ const plano = { novo: [], atualiza: [], preserva: [], igual: [], protegido: [] };
477
+
478
+ for (const rel of listar(raizNova)) {
479
+ if (ehIntocavel(rel)) { plano.protegido.push(rel); continue; }
480
+
481
+ const aqui = join(raizAtual, rel);
482
+ if (!existsSync(aqui)) { plano.novo.push(rel); continue; }
483
+
484
+ const hAqui = hashDe(aqui);
485
+ const hNovo = hashDe(join(raizNova, rel));
486
+ if (hAqui === hNovo) { plano.igual.push(rel); continue; }
487
+
488
+ // difere do pacote novo. Foi o dono que mexeu, ou é a correção chegando?
489
+ // Metadados do pacote são do sistema por definição — o dono nunca os
490
+ // edita. Preservar o VERSION deixaria o update dizendo "já está na versão
491
+ // mais recente" para sempre numa instalação sem manifesto.
492
+ if (SEMPRE_ATUALIZA.includes(rel)) { plano.atualiza.push(rel); continue; }
493
+
494
+ if (orig) {
495
+ const hOrig = orig[rel];
496
+ // sem registro de origem = arquivo que o dono criou depois: preserva
497
+ if (!hOrig) { plano.preserva.push(rel); continue; }
498
+ // igual ao que foi instalado = intocado pelo dono: pode atualizar
499
+ if (hAqui === hOrig) { plano.atualiza.push(rel); continue; }
500
+ plano.preserva.push(rel);
501
+ } else {
502
+ // Sem manifesto não dá para saber quem alterou o quê. Preservar TUDO
503
+ // parece seguro, mas trava o update onde ele mais importa: numa
504
+ // instalação antiga, os arquivos do MOTOR (scripts, hooks, instalador)
505
+ // são exatamente os que carregam a correção — e quase nunca são
506
+ // editados pelo dono. Preservá-los entregaria um update que não
507
+ // atualiza nada, com um .novo inerte ao lado.
508
+ //
509
+ // Então: motor atualiza, conteúdo preserva. E o que for sobrescrito
510
+ // ganha backup .antigo, para nada ser perdido sem volta.
511
+ plano[ehDoMotor(rel) ? 'atualiza' : 'preserva'].push(rel);
512
+ }
513
+ }
514
+ return plano;
515
+ }
516
+
517
+ function mostrarPlano(plano, semManifesto) {
518
+ const l = (rot, arr, cor) => {
519
+ if (!arr.length) return;
520
+ console.log(` ${cor(String(arr.length).padStart(5))} ${branco(rot)}`);
521
+ };
522
+ console.log();
523
+ console.log(` ${branco(neg('O que vai acontecer'))}`);
524
+ console.log();
525
+ l('arquivos novos', plano.novo, verde);
526
+ l('atualizados', plano.atualiza, verde);
527
+ l('preservados (você alterou)', plano.preserva, laranja);
528
+ l('já estavam iguais', plano.igual, fraco);
529
+ l('protegidos (memória, workspace, .env)', plano.protegido, cinza);
530
+ console.log();
531
+
532
+ if (plano.preserva.length) {
533
+ const amostra = plano.preserva.slice(0, 6);
534
+ console.log(` ${laranja('Preservados')} ${fraco('— fica a SUA versão, e guardo a nova ao lado como .novo')}`);
535
+ for (const a of amostra) console.log(` ${fraco('·')} ${cinza(a)}`);
536
+ if (plano.preserva.length > amostra.length) {
537
+ console.log(` ${fraco(`· e mais ${plano.preserva.length - amostra.length}`)}`);
538
+ }
539
+ console.log();
540
+ }
541
+
542
+ if (semManifesto) {
543
+ console.log(` ${laranja('!')} ${cinza('Sua instalação é anterior ao registro de arquivos.')}`);
544
+ console.log(` ${fraco('Sem ele não sei o que você alterou. Então atualizo o motor do sistema')}`);
545
+ console.log(` ${fraco('(hooks, scripts, instalador) — é onde a correção vem — e preservo o')}`);
546
+ console.log(` ${fraco('resto. O que for sobrescrito fica salvo como .antigo, e dá para voltar.')}`);
547
+ console.log(` ${fraco('A partir desta atualização, o registro passa a existir.')}`);
548
+ console.log();
549
+ }
550
+ }
551
+
552
+ function aplicar(raizAtual, raizNova, plano) {
553
+ let n = 0;
554
+ for (const rel of [...plano.novo, ...plano.atualiza]) {
555
+ const destino = join(raizAtual, rel);
556
+ mkdirSync(join(destino, '..'), { recursive: true });
557
+ // guarda o anterior quando o conteúdo difere: sem manifesto a decisão de
558
+ // atualizar é uma aposta informada, e aposta precisa ter volta.
559
+ if (existsSync(destino) && hashDe(destino) !== hashDe(join(raizNova, rel))) {
560
+ try { copyFileSync(destino, destino + '.antigo'); } catch {}
561
+ }
562
+ copyFileSync(join(raizNova, rel), destino);
563
+ n++;
564
+ }
565
+ // o que o dono alterou fica intacto; a versão nova vai ao lado, para ele ver
566
+ for (const rel of plano.preserva) {
567
+ copyFileSync(join(raizNova, rel), join(raizAtual, rel + '.novo'));
568
+ }
569
+ return n;
570
+ }
571
+
572
+ async function atualizar(args) {
573
+ abertura();
574
+
575
+ const raiz = resolve(process.cwd(), args[0] || '.');
576
+ const marca = join(raiz, '.claude', 'hooks');
577
+ if (!existsSync(marca)) {
578
+ console.log(` ${vermelho(neg('Não encontrei um JARVIS aqui.'))}`);
579
+ console.log(` ${cinza('Rode este comando DENTRO da pasta do JARVIS, ou passe o caminho:')}`);
580
+ console.log(` ${fraco('npx @fefsbenson/jarvis update C:\\Users\\voce\\jarvis')}`);
581
+ console.log();
582
+ return 1;
583
+ }
584
+
585
+ const versaoAtual = existsSync(join(raiz, 'VERSION'))
586
+ ? readFileSync(join(raiz, 'VERSION'), 'utf8').trim() : '?';
587
+
588
+ const py = acharPython();
589
+ const plataforma = existsSync(join(raiz, '.claude', 'hooks', 'pyrun.py'))
590
+ ? 'windows' : 'linux';
591
+
592
+ console.log(` ${verde('✓')} ${branco('JARVIS encontrado')} ${fraco(raiz)}`);
593
+ console.log(` ${verde('✓')} ${branco('Versão atual')} ${cinza('v' + versaoAtual)}` +
594
+ ` ${fraco('edição ' + PLATAFORMAS[plataforma].rotulo)}`);
595
+ console.log();
596
+ console.log(` ${cinza('Vou baixar a versão mais recente e trazer só o que mudou.')}`);
597
+ console.log(` ${cinza('O que você alterou fica como está — nada seu é sobrescrito.')}`);
598
+ console.log();
599
+
600
+ // Pacote já em disco: pula o download. Serve a dois casos reais — o dono que
601
+ // recebeu o pacote por outro meio (rede local, pen drive), e o TESTE, que
602
+ // precisa exercitar este caminho sem rede nem token. Sem esta porta, a única
603
+ // forma de provar o update é baixando de verdade, e o que não se testa não
604
+ // se garante.
605
+ const localIdx = args.indexOf('--de');
606
+ const pacoteLocal = localIdx >= 0 ? resolve(args[localIdx + 1] || '') : null;
607
+
608
+ let tmp;
609
+ if (pacoteLocal) {
610
+ if (!existsSync(join(pacoteLocal, 'VERSION'))) {
611
+ console.log(`\n ${vermelho(neg('Isso não parece um pacote do JARVIS.'))}`);
612
+ console.log(` ${cinza(pacoteLocal + ' — não tem VERSION')}\n`);
613
+ return 1;
614
+ }
615
+ tmp = pacoteLocal;
616
+ console.log(` ${verde('✓')} ${branco('Usando o pacote local')} ${fraco(tmp)}`);
617
+ } else {
618
+ const token = process.env.JARVIS_TOKEN || await perguntar('Token:', true);
619
+ if (!token) {
620
+ console.log(`\n ${fraco('Sem token, não há o que baixar. Cancelado.')}\n`);
621
+ return 130;
622
+ }
623
+
624
+ tmp = join(tmpdir(), `jarvis-update-${Date.now()}`);
625
+ console.log();
626
+ try {
627
+ await baixar(token, tmp, plataforma);
628
+ } catch (e) {
629
+ if (e.message === 'TOKEN_INVALIDO' || e.message === 'SEM_ACESSO') erroDeAcesso(e.message);
630
+ else {
631
+ console.log(`\n ${vermelho(neg('Não consegui baixar.'))}`);
632
+ console.log(` ${cinza(e.message)}\n`);
633
+ }
634
+ return 1;
635
+ }
636
+ }
637
+
638
+ // NUNCA apagar um diretório que o dono nos deu. Com `--de`, `tmp` É o pacote
639
+ // dele — removê-lo seria destruir a origem no meio de uma operação cujo
640
+ // propósito inteiro é não destruir nada.
641
+ const limparTmp = () => {
642
+ if (!pacoteLocal) rmSync(tmp, { recursive: true, force: true });
643
+ };
644
+
645
+ const versaoNova = existsSync(join(tmp, 'VERSION'))
646
+ ? readFileSync(join(tmp, 'VERSION'), 'utf8').trim() : '?';
647
+
648
+ if (versaoNova === versaoAtual) {
649
+ console.log();
650
+ console.log(` ${verde('✓')} ${branco('Você já está na versão mais recente')} ${cinza('(v' + versaoNova + ')')}`);
651
+ console.log();
652
+ limparTmp();
653
+ return 0;
654
+ }
655
+
656
+ // Sanidade do que foi baixado, ANTES de encostar na pasta do dono.
657
+ // Um pacote que extrai pela metade (rede caindo, disco cheio) não faz o tar
658
+ // falhar necessariamente — e aplicar um pacote incompleto por cima de uma
659
+ // instalação boa é exatamente o estrago que este comando não pode causar.
660
+ const marcasEsperadas = ['.claude/hooks', '.claude/scripts', 'VERSION', 'install.py'];
661
+ const faltando = marcasEsperadas.filter(m => !existsSync(join(tmp, m)));
662
+ const totalBaixado = listar(tmp).length;
663
+ if (faltando.length || totalBaixado < 500) {
664
+ console.log();
665
+ console.log(` ${vermelho(neg('O pacote baixado veio incompleto.'))}`);
666
+ console.log(` ${cinza(faltando.length ? 'falta: ' + faltando.join(', ')
667
+ : `só ${totalBaixado} arquivos`)}`);
668
+ console.log();
669
+ console.log(` ${branco('Sua instalação NÃO foi tocada.')} ${fraco('Tente de novo.')}`);
670
+ console.log();
671
+ limparTmp();
672
+ return 1;
673
+ }
674
+
675
+ const manifesto = lerManifesto(raiz);
676
+ const plano = planejar(raiz, tmp, manifesto);
677
+
678
+ console.log();
679
+ console.log(` ${branco(neg(`v${versaoAtual}`))} ${fraco('→')} ${verde(neg(`v${versaoNova}`))}`);
680
+ mostrarPlano(plano, !manifesto);
681
+
682
+ if (!plano.novo.length && !plano.atualiza.length) {
683
+ console.log(` ${verde('✓')} ${branco('Nada a mudar nos arquivos do sistema.')}`);
684
+ console.log();
685
+ limparTmp();
686
+ return 0;
687
+ }
688
+
689
+ // `--seco`: mostra o plano e para. É o "ver antes de aplicar" que o dono
690
+ // deve usar sempre na primeira vez — e o único modo em que este comando
691
+ // não escreve nada, por construção.
692
+ if (args.includes('--seco')) {
693
+ console.log(` ${fraco('(--seco: nada foi escrito)')}\n`);
694
+ limparTmp();
695
+ return 0;
696
+ }
697
+
698
+ // `--sim` pula a confirmação. Existe para automação e teste; o caminho
699
+ // normal do dono continua perguntando, porque a decisão é dele.
700
+ if (!args.includes('--sim')) {
701
+ const ok = (await perguntar(`Aplicar? ${fraco('(enter = sim, n = cancelar)')}`)).trim().toLowerCase();
702
+ if (ok === 'n' || ok === 'nao' || ok === 'não') {
703
+ console.log(`\n ${fraco('Cancelado. Nada foi alterado.')}\n`);
704
+ limparTmp();
705
+ return 130;
706
+ }
707
+ }
708
+
709
+ console.log();
710
+ const n = aplicar(raiz, tmp, plano);
711
+ console.log(` ${verde('✓')} ${branco('Aplicado')} ${fraco(n.toLocaleString('pt-BR') + ' arquivo(s)')}`);
712
+ limparTmp();
713
+
714
+ // O settings.json que veio no pacote traz o caminho como `$CLAUDE_PROJECT_DIR`
715
+ // — sintaxe POSIX. Precisa virar o caminho ABSOLUTO desta máquina, senão no
716
+ // Windows os 105 hooks morrem (foi o defeito que quebrou a instalação de um
717
+ // aluno em 24/08).
718
+ //
719
+ // Chamar o médico com --fix NÃO basta: em Linux o exame passa (lá a variável
720
+ // expande), então o conserto nem é acionado e o marcador fica. Por isso a
721
+ // regravação é INCONDICIONAL — o install.py sabe fazê-la, e é a mesma função
722
+ // que roda em toda instalação.
723
+ if (py) {
724
+ console.log();
725
+ console.log(` ${cinza('Ajustando os caminhos para esta máquina...')}`);
726
+ const fix = spawnSync(py.cmd, [...(py.args || []), '-c',
727
+ 'import sys;sys.path.insert(0,sys.argv[1]);import install;' +
728
+ 'r=install.fixar_launcher_dos_hooks();print(r[0],r[1])', raiz],
729
+ { encoding: 'utf8', cwd: raiz });
730
+ if (fix.status === 0) {
731
+ console.log(` ${verde('✓')} ${branco('Caminhos e launcher gravados')} ${fraco((fix.stdout || '').trim())}`);
732
+ } else {
733
+ console.log(` ${laranja('!')} ${cinza('Não consegui regravar os caminhos automaticamente.')}`);
734
+ console.log(` ${fraco('Rode: ' + py.cmd + ' .claude/scripts/jarvis_doctor.py --fix')}`);
735
+ }
736
+
737
+ // e o registro de arquivos precisa refletir a versão que acabou de entrar,
738
+ // senão a PRÓXIMA atualização compara contra a versão errada
739
+ spawnSync(py.cmd, [...(py.args || []), '-c',
740
+ 'import sys;sys.path.insert(0,sys.argv[1]);import install;install.gravar_impressao_digital()', raiz],
741
+ { encoding: 'utf8', cwd: raiz });
742
+
743
+ console.log();
744
+ console.log(` ${cinza('Conferindo a instalação...')}`);
745
+ console.log();
746
+ const r = spawnSync(py.cmd, [...(py.args || []),
747
+ join(raiz, '.claude', 'scripts', 'jarvis_doctor.py')],
748
+ { stdio: 'inherit', cwd: raiz });
749
+ if (r.status !== 0) {
750
+ console.log();
751
+ console.log(` ${laranja('!')} ${cinza('O médico apontou algo. Veja acima o que ficou pendente.')}`);
752
+ }
753
+ }
754
+
755
+ console.log();
756
+ console.log(` ${verde(neg('Atualizado para v' + versaoNova + '.'))}`);
757
+ if (plano.preserva.length) {
758
+ console.log(` ${fraco('Seus arquivos alterados continuam lá; a versão nova de cada um está como *.novo')}`);
759
+ }
760
+ console.log();
761
+ return 0;
762
+ }
763
+
326
764
  function ajuda() {
327
765
  abertura();
328
766
  console.log(` ${branco('npx @fefsbenson/jarvis install')} ${fraco('baixa e instala em ./jarvis')}`);
329
767
  console.log(` ${branco('npx @fefsbenson/jarvis install PASTA')} ${fraco('instala na pasta que você escolher')}`);
330
768
  console.log();
769
+ console.log(` ${branco('npx @fefsbenson/jarvis update')} ${fraco('atualiza o JARVIS desta pasta')}`);
770
+ console.log(` ${branco('npx @fefsbenson/jarvis update PASTA')} ${fraco('atualiza o de outra pasta')}`);
771
+ console.log(` ${fraco(' o update preserva o que você alterou — nada seu é sobrescrito')}`);
772
+ console.log(` ${branco('npx @fefsbenson/jarvis update --seco')} ${fraco('mostra o que mudaria, sem escrever nada')}`);
773
+ console.log();
331
774
  console.log(` ${fraco('Precisa de: Node 18+, Python 3.10+ e o token de acesso.')}`);
332
775
  console.log(` ${fraco('O token também pode vir da variável JARVIS_TOKEN.')}`);
333
776
  console.log();
@@ -336,6 +779,8 @@ function ajuda() {
336
779
  const [, , comando, ...resto] = process.argv;
337
780
  if (comando === 'install') {
338
781
  instalar(resto).then(cod => process.exit(cod));
782
+ } else if (comando === 'update' || comando === 'atualizar') {
783
+ atualizar(resto).then(cod => process.exit(cod));
339
784
  } else {
340
785
  ajuda();
341
786
  process.exit(comando ? 1 : 0);
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * O update do cli.js preserva o que é do dono?
4
+ *
5
+ * Existe porque a política de preservação vive em DOIS lugares — aqui e no
6
+ * `atualizar.py` da instalação. Divergir significa que o mesmo arquivo é
7
+ * sagrado num caminho e apagado no outro, e ninguém percebe até um cliente
8
+ * perder trabalho.
9
+ *
10
+ * Rodar: node bin/teste-preservacao.mjs
11
+ */
12
+ import { readFileSync } from 'node:fs';
13
+ import { join, dirname } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const cli = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'cli.js'), 'utf8');
17
+ const trecho = cli.slice(cli.indexOf('const INTOCAVEIS'), cli.indexOf('function listar('));
18
+ const { ehIntocavel } = await import(
19
+ 'data:text/javascript,' + encodeURIComponent(trecho + '\nexport { ehIntocavel };'));
20
+
21
+ // [caminho, protegido?, por quê]
22
+ const CASOS = [
23
+ ['.env', true, 'credencial'],
24
+ ['jarvis.config.yaml', true, 'config do dono'],
25
+ ['.claude/settings.local.json', true, 'overrides da máquina dele'],
26
+ ['.claude/jarvis/STATE.json', true, 'estado'],
27
+ ['.claude/sessions/s.md', true, 'histórico'],
28
+ ['.claude/mission-control/S.json', true, 'estado de missão'],
29
+ ['.claude/trash/x.md', true, 'lixeira dele'],
30
+ ['workspace/businesses/x/brand.md', true, 'o negócio dele'],
31
+ ['knowledge/business/insights/r.md', true, 'bucket dele'],
32
+ ['knowledge/personal/cognitive/d.md', true, 'bucket dele'],
33
+ ['knowledge/external/inbox/CURSO/a.txt', true, 'material que ELE ingeriu'],
34
+ ['outputs/relatorio.md', true, 'produção dele (L3)'],
35
+ ['research/analise.md', true, 'produção dele (L3)'],
36
+ ['logs/x.jsonl', true, 'registro'],
37
+ ['.data/rag/x.db', true, 'índice'],
38
+ ['artifacts/x.json', true, 'saída de pipeline'],
39
+ ['processing/x.json', true, 'artefato de pipeline'],
40
+ ['agents/external/ah/MEMORY.md', true, 'memória acumulada do agente'],
41
+ ['agents/external/ah/SOUL.md', true, 'voz calibrada do agente'],
42
+ // do sistema — DEVEM receber a correção
43
+ ['agents/external/ah/AGENT.md', false, 'definição vem do pacote'],
44
+ ['agents/external/ah/DNA-CONFIG.yaml', false, 'ponteiro vem do pacote'],
45
+ ['knowledge/external/dna/persons/ah/H.yaml',false, 'DNA curado — L2'],
46
+ ['knowledge/external/dossiers/d.md', false, 'dossiê curado — L2'],
47
+ ['knowledge/external/playbooks/p.md', false, 'playbook curado — L2'],
48
+ ['.claude/rules/goal-mindset.md', false, 'rule do sistema'],
49
+ ['.claude/hooks/x.py', false, 'hook do sistema'],
50
+ ['.claude/skills/jarvis/SKILL.md', false, 'skill do sistema'],
51
+ ['core/paths.py', false, 'motor'],
52
+ ['install.py', false, 'instalador'],
53
+ ['VERSION', false, 'metadado — atualiza'],
54
+ ['.claude/jarvis/instalacao-original.json', false, 'baseline — precisa renovar'],
55
+ ];
56
+
57
+ let falhas = 0;
58
+ for (const [rel, esperado, porque] of CASOS) {
59
+ const got = ehIntocavel(rel);
60
+ const ok = got === esperado;
61
+ if (!ok) falhas++;
62
+ console.log(` ${ok ? 'PASS' : 'FALHA'} protegido=${String(got).padEnd(5)} ${rel.padEnd(44)} ${porque}`);
63
+ }
64
+ console.log(`\n ${falhas ? `${falhas} FALHA(S)` : `${CASOS.length}/${CASOS.length} PASSARAM`}`);
65
+ process.exit(falhas ? 1 : 0);
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env python3
2
+ """BATERIA 6 — o risco que a fronteira larga criou.
3
+
4
+ Ampliar `ehDoMotor` para skills/rules/agents significa que, SEM manifesto, um
5
+ arquivo desses que o dono editou é sobrescrito. Isso só é aceitável porque o
6
+ `.antigo` existe. Esta bateria prova que ele existe SEMPRE, e que o conteúdo
7
+ do dono está lá dentro — recuperável com um `mv`.
8
+ """
9
+ import hashlib, json, shutil, subprocess, sys, tempfile
10
+ from pathlib import Path
11
+
12
+ PAC = Path.home()/"jarvis-dist"
13
+ CLI = Path.home()/"jarvis-npx/bin/cli.js"
14
+
15
+
16
+ def h(p): return hashlib.sha256(p.read_bytes()).hexdigest()
17
+
18
+
19
+ # O dono editou arquivos do SISTEMA — cada família que a fronteira agora cobre
20
+ EDITADOS = {
21
+ ".claude/rules/goal-mindset.md": "# regra do sistema COM MINHA ANOTACAO\n",
22
+ ".claude/skills/jarvis/SKILL.md": "# skill do sistema QUE EU AJUSTEI\n",
23
+ ".claude/hooks/hook_output.py": "# hook QUE EU MEXI\n",
24
+ "agents/system/decisor/AGENT.md": "# agente QUE EU CUSTOMIZEI\n",
25
+ "core/intelligence/agents/activation_generator.py": "# core QUE EU MUDEI\n",
26
+ }
27
+
28
+
29
+ def montar(com_manifesto):
30
+ B = Path(tempfile.gettempdir())/f"bkp-{'m' if com_manifesto else 's'}"
31
+ if B.exists(): shutil.rmtree(B, ignore_errors=True)
32
+ inst = B/"inst"
33
+ for a in (".claude/hooks", ".claude/rules", ".claude/skills/jarvis",
34
+ "agents/system", "core/intelligence/agents"):
35
+ shutil.copytree(PAC/a, inst/a, dirs_exist_ok=True,
36
+ ignore=shutil.ignore_patterns("__pycache__"))
37
+ for f in ("VERSION", ".claude/settings.json", "install.py"):
38
+ (inst/f).parent.mkdir(parents=True, exist_ok=True)
39
+ shutil.copy2(PAC/f, inst/f)
40
+ (inst/"VERSION").write_text("1.1.23\n")
41
+ if com_manifesto:
42
+ # manifesto do estado LIMPO (antes das edições do dono)
43
+ arq = {str(f.relative_to(inst)).replace("\\","/"): h(f)
44
+ for f in inst.rglob("*") if f.is_file() and "__pycache__" not in str(f)}
45
+ mp = inst/".claude/jarvis/instalacao-original.json"
46
+ mp.parent.mkdir(parents=True, exist_ok=True)
47
+ mp.write_text(json.dumps({"versao":"1.1.23","arquivos":arq}, indent=2))
48
+ for rel, txt in EDITADOS.items():
49
+ (inst/rel).write_text(txt, encoding="utf-8")
50
+ return inst
51
+
52
+
53
+ falhas = 0
54
+ for com_man in (True, False):
55
+ rot = "COM manifesto (deve PRESERVAR)" if com_man else "SEM manifesto (sobrescreve COM backup)"
56
+ print(f"\n ── {rot} ──")
57
+ inst = montar(com_man)
58
+ subprocess.run(["node", str(CLI), "update", str(inst), "--de", str(PAC), "--sim"],
59
+ capture_output=True, text=True, timeout=600)
60
+ for rel, meu in EDITADOS.items():
61
+ p = inst/rel
62
+ atual = p.read_text(encoding="utf-8", errors="replace")
63
+ marca = meu.strip()
64
+ no_lugar = marca in atual
65
+ bkp = inst/(rel + ".antigo")
66
+ no_backup = bkp.is_file() and marca in bkp.read_text(encoding="utf-8", errors="replace")
67
+ novo = inst/(rel + ".novo")
68
+
69
+ if com_man:
70
+ # com manifesto: o arquivo do dono FICA, e a versão nova vai .novo
71
+ ok = no_lugar and novo.is_file()
72
+ detalhe = f"no_lugar={no_lugar} tem_.novo={novo.is_file()}"
73
+ else:
74
+ # sem manifesto: é sobrescrito, MAS o dele tem que estar no .antigo
75
+ ok = no_backup
76
+ detalhe = f"no_lugar={no_lugar} no_backup={no_backup}"
77
+ falhas += not ok
78
+ print(f" {'PASS' if ok else 'FALHA'} {rel}" + ("" if ok else f" -> {detalhe}"))
79
+
80
+ # recuperável com um comando? (o teste que prova que o backup SERVE)
81
+ if not com_man:
82
+ rel = ".claude/rules/goal-mindset.md"
83
+ shutil.move(inst/(rel + ".antigo"), inst/rel)
84
+ ok = EDITADOS[rel].strip() in (inst/rel).read_text(encoding="utf-8")
85
+ falhas += not ok
86
+ print(f" {'PASS' if ok else 'FALHA'} recuperável com um `mv arquivo.antigo arquivo`")
87
+
88
+ print(f"\n BATERIA 6: {'TODOS PASSARAM' if not falhas else f'{falhas} FALHA(S)'}")
89
+ sys.exit(1 if falhas else 0)