@andespindola/crow-skills 0.10.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Skills com escopo explícito, conhecimento especializado preservado e fluxos comuns reutilizáveis.
4
4
 
5
- **Versão 0.10.0** ([mudanças](CHANGELOG.md)): o crow-broadcast compartilha, de forma passiva, o que cada sessão e cada CLI (Claude Code, Codex, Gemini CLI, Antigravity, Devin, inclusive dentro do Uniflow) está fazendo nesta máquina: repositório, clone, branch, execução crow em andamento e arquivos em edição; o prompt recebe um resumo só quando outra sessão viva está no mesmo repositório, editar um arquivo que outra sessão tocou ou trocar de branch num clone em uso gera aviso (ou bloqueio, se configurado), e `crow-skills broadcast claim` reserva áreas; a crow-behaviour consulta isso antes de planejar e reserva as áreas que delega ([crow-broadcast](docs/broadcast.md)). A 0.9.0 trouxe o controle de memória da máquina: `crow-skills resources` mede memória, pressão e os CLIs de agente em execução e recomenda quantos agentes, jobs pesados e workers rodar em paralelo; a crow-behaviour consulta essa recomendação antes de cada fan-out (sob `warn` reduz, sob `critical` segue sequencial), limita workers de builds e testes, reutiliza um dev server por projeto e encerra o que abriu, e 15 especialistas que sobem processos pesados ganharam a regra correspondente; o hook de prompt e as status lines só avisam quando a máquina está sob pressão. A 0.8.0 trouxe `crow-skills mode on|off`, que liga e desliga a crow-behaviour em todas as sessões e CLIs (Claude Code, Codex, Gemini CLI, Antigravity, Devin) a partir do próximo prompt, com `/crow on|off` dentro da sessão e o indicador `▸▸ crow on` na status line; a crow-behaviour mostra o progresso como a lista de agentes do host (`● main`, `○ dono atividade`); `crow-skills events` publica um barramento de eventos dos hooks para terminais; `crow-skills autoupdate on` atualiza o CLI e as skills gerenciadas todo dia e no login. A 0.7.1 trouxe as correções da revalidação no Codex às skills especialistas; a 0.7.0 tornou o Uniflow agente de instalação (`--agents uniflow`); a 0.6.1 passou a mostrar o progresso da crow-behaviour na própria conversa; a 0.6.0 trouxe as regras de entrega das 32 skills especialistas, a partir da [validação no Codex](docs/evaluations/2026-10-26-codex.md).
5
+ **Versão 0.10.1** ([mudanças](CHANGELOG.md)): o controle de recursos passa a tratar swap e CPU como eixos de pressão (swap quase cheio e carga alta reduzem agentes, jobs e workers; nenhuma recomendação cresce com a pressão), mostra a CPU% dos CLIs de agente, traz `crow-skills resources --exec-prefix` para rodar jobs pesados em prioridade baixa e as regras da crow-behaviour sob pressão (reduzir já, um job pesado por vez, não repetir verificação, validação ampla no CI); os hooks do Codex vão para o `config.toml` quando o `hooks.json` é symlink. A 0.10.0 trouxe o crow-broadcast, que compartilha, de forma passiva, o que cada sessão e cada CLI (Claude Code, Codex, Gemini CLI, Antigravity, Devin, inclusive dentro do Uniflow) está fazendo nesta máquina: repositório, clone, branch, execução crow em andamento e arquivos em edição; o prompt recebe um resumo só quando outra sessão viva está no mesmo repositório, editar um arquivo que outra sessão tocou ou trocar de branch num clone em uso gera aviso (ou bloqueio, se configurado), e `crow-skills broadcast claim` reserva áreas; a crow-behaviour consulta isso antes de planejar e reserva as áreas que delega ([crow-broadcast](docs/broadcast.md)). A 0.9.0 trouxe o controle de memória da máquina: `crow-skills resources` mede memória, pressão e os CLIs de agente em execução e recomenda quantos agentes, jobs pesados e workers rodar em paralelo; a crow-behaviour consulta essa recomendação antes de cada fan-out (sob `warn` reduz, sob `critical` segue sequencial), limita workers de builds e testes, reutiliza um dev server por projeto e encerra o que abriu, e 15 especialistas que sobem processos pesados ganharam a regra correspondente; o hook de prompt e as status lines só avisam quando a máquina está sob pressão. A 0.8.0 trouxe `crow-skills mode on|off`, que liga e desliga a crow-behaviour em todas as sessões e CLIs (Claude Code, Codex, Gemini CLI, Antigravity, Devin) a partir do próximo prompt, com `/crow on|off` dentro da sessão e o indicador `▸▸ crow on` na status line; a crow-behaviour mostra o progresso como a lista de agentes do host (`● main`, `○ dono atividade`); `crow-skills events` publica um barramento de eventos dos hooks para terminais; `crow-skills autoupdate on` atualiza o CLI e as skills gerenciadas todo dia e no login. A 0.7.1 trouxe as correções da revalidação no Codex às skills especialistas; a 0.7.0 tornou o Uniflow agente de instalação (`--agents uniflow`); a 0.6.1 passou a mostrar o progresso da crow-behaviour na própria conversa; a 0.6.0 trouxe as regras de entrega das 32 skills especialistas, a partir da [validação no Codex](docs/evaluations/2026-10-26-codex.md).
6
6
 
7
7
  **Reconstrução em andamento.** A primeira versão com 32 contratos genéricos não atende à preservação de capacidades solicitada. Não use esse número nem o mapeamento por categoria como prova de substituição das originais. A revisão passa a abranger cada definição e seus recursos, modos e dependências, sem número fixo de skills ou limite artificial de linhas. Veja [critérios e progresso](docs/reconstruction.md).
8
8
 
@@ -30,7 +30,7 @@ Exemplos:
30
30
  O pacote de cada skill é somente sua pasta em `skills/`. O [CLI crow-skills](docs/cli.md) instala cópias completas e versionadas em agentes/escopos explícitos, com preview, saída JSON, detecção de conflitos e recuperação. Requer Python 3.10+; Git para fontes em clone, Node 18+ para o launcher npm; mutações em macOS/Linux. A distribuição npm privada `@andespindola/crow-skills` inclui um snapshot completo das skills, sem clone ou hooks que as instalem automaticamente. No [Uniflow](docs/cli.md#uniflow), `--agents uniflow --global` grava as skills no store dele (`~/.config/uniflow/skills`), sem tocar no resto da configuração. Em `--global`, instalar a crow-behaviour também grava o guia aditivo do agente (claude, codex, gemini, devin) para usar as skills; `--no-guide` desliga. As integrações de interface (status lines do Claude Code, do Codex e do Antigravity, hooks do modo crow em cada CLI, comando `/crow`, segmento tmux) seguem o mesmo fluxo, preservando a configuração existente; `--no-integrations` desliga. O [barramento de eventos](docs/events.md) e a atualização automática (`crow-skills autoupdate on`) estão no [CLI](docs/cli.md).
31
31
 
32
32
  ```sh
33
- npm install -g @andespindola/crow-skills@0.10.0
33
+ npm install -g @andespindola/crow-skills@0.10.1
34
34
  crow-skills mode
35
35
  crow-skills progress
36
36
  ./bin/crow-skills list
@@ -1,3 +1,3 @@
1
1
  """Portable, local-first installer for the crow-skills catalogue."""
2
2
 
3
- __version__ = "0.10.0"
3
+ __version__ = "0.10.1"
@@ -96,6 +96,8 @@ def parser():
96
96
  child = sub.add_parser("resources", parents=[common],
97
97
  help="Memória, pressão, agentes em execução e paralelismo recomendado (somente leitura)")
98
98
  child.add_argument("--no-processes", action="store_true", help="Não varrer os processos dos CLIs de agente (mais rápido)")
99
+ child.add_argument("--exec-prefix", action="store_true",
100
+ help="Só imprimir o prefixo que roda um job pesado em prioridade baixa neste sistema (taskpolicy/nice/ionice/start /low)")
99
101
  child = sub.add_parser("autoupdate", parents=[common], help="Atualização diária e no login do crow-skills e das skills gerenciadas")
100
102
  child.add_argument("action", choices=("on", "off", "status", "run"))
101
103
  child.add_argument("--git-pull", action="store_true", help="on: também atualizar um checkout git limpo em main/master (git pull --ff-only)")
@@ -622,6 +624,8 @@ def run(args):
622
624
  if args.command == "broadcast":
623
625
  return broadcast_command(args, home)
624
626
  if args.command == "resources":
627
+ if args.exec_prefix:
628
+ return resources.exec_prefix_data()
625
629
  data = resources.probe(home, processes=not args.no_processes)
626
630
  data["text"] = resources.render(data)
627
631
  return data
@@ -100,11 +100,14 @@ def resources_field(home):
100
100
  """crow-resources/1 subset for terminals: memory, pressure and the recommendation.
101
101
 
102
102
  Added in 0.9.0 without changing crow-snapshot/1: consumers that do not know the
103
- field ignore it; the field carries its own schema for future changes.
103
+ field ignore it; the field carries its own schema for future changes. 0.10.1 adds
104
+ pressure.swap and cpu (additive).
104
105
  """
105
106
  data = resources.probe(home, processes=False, use_cache=True)
106
107
  return {"schema": data["schema"], "generatedAt": data["generatedAt"], "cached": data["cached"],
107
- "memory": data["memory"], "pressure": {"level": data["pressure"]["level"], "reasons": data["pressure"]["reasons"]},
108
+ "memory": data["memory"], "pressure": {"level": data["pressure"]["level"], "reasons": data["pressure"]["reasons"],
109
+ "swap": data["pressure"].get("swap")},
110
+ "cpu": {k: data["cpu"].get(k) for k in ("count", "load1", "load5", "load15", "loadPerCpu1", "loadPerCpu5", "level", "reasons")},
108
111
  "recommendation": data["recommendation"], "alert": resources.alert(data), "segment": resources.segment(data)}
109
112
 
110
113
 
@@ -60,6 +60,9 @@ BROADCAST_SCRIPT = "crow_broadcast.py"
60
60
  COMMAND_HEADER = "Gerenciado por crow-skills (comando /crow). Não edite; use crow-skills integrations."
61
61
  BEGIN = "# >>> crow-skills integrations (gerenciado; use crow-skills integrations uninstall) >>>"
62
62
  END = "# <<< crow-skills integrations <<<"
63
+ # codex-hooks inside ~/.codex/config.toml, used when ~/.codex/hooks.json is a symlink (a dotfiles repo) or unsafe.
64
+ HOOKS_BEGIN = "# >>> crow-skills codex-hooks (gerenciado; use crow-skills integrations uninstall) >>>"
65
+ HOOKS_END = "# <<< crow-skills codex-hooks <<<"
63
66
  CODEX_ITEMS = ["model-with-reasoning", "context-remaining", "current-dir", "task-progress", "used-tokens"]
64
67
  SNIPPET_HEADER = "# Gerenciado por crow-skills (integração tmux). Não edite; use crow-skills integrations."
65
68
  UNSUPPORTED = {
@@ -494,6 +497,24 @@ def add_hooks(data, entries, layout):
494
497
 
495
498
 
496
499
  def hooks_sync(name, store, home, record, desired, python, dry):
500
+ if name == "codex-hooks":
501
+ unsafe = codex_hooks_json_unsafe(home)
502
+ if record and record.get("mode") != "toml" and unsafe:
503
+ # Installed into a regular hooks.json that has since become a symlink (moved into dotfiles):
504
+ # our entries live in the target now. Keep the record (and the script they call) until the
505
+ # user removes them; then the hooks move to config.toml on the next install.
506
+ left = codex_json_duplicates(home)
507
+ if left:
508
+ return record, "skip", ("~/.codex/hooks.json virou symlink (ou ficou inseguro) depois que o crow-skills gravou nele: "
509
+ + str(left) + " entrada(s) do crow-skills com crow_hook.py continuam no arquivo de destino, "
510
+ "que o crow-skills não edita. Remova-as de lá" + (" e rode crow-skills integrations install de novo: os hooks "
511
+ "passam para ~/.codex/config.toml." if desired else "; o registro e o script que elas chamam ficam "
512
+ "até isso, para o Codex não chamar um script apagado."))
513
+ record = None
514
+ if not desired:
515
+ return None, "skip", "Entradas do crow-skills já ausentes de ~/.codex/hooks.json; controle encerrado."
516
+ if (record or {}).get("mode") == "toml" or (unsafe and (desired or not record)):
517
+ return codex_hooks_toml_sync(store, home, record, desired, python, dry, unsafe)
497
518
  spec = HOOKS[name]
498
519
  target = spec["file"](home)
499
520
  if desired and not record and spec["requires"] is not None and not os.path.exists(spec["requires"](home)):
@@ -535,6 +556,192 @@ def hooks_sync(name, store, home, record, desired, python, dry):
535
556
  return new_record, action, note
536
557
 
537
558
 
559
+ # --- codex-hooks in config.toml ------------------------------------------------------------------
560
+ # Codex 0.165 reads hooks from hooks.json and from config.toml (HooksToml: one array of matcher
561
+ # groups per event, each {matcher?, hooks: [{type = "command", command, timeout, async, ...}]};
562
+ # the same schema as hooks.json, read from the binary). When ~/.codex/hooks.json is a symlink
563
+ # (typically into a dotfiles repository) crow-skills never writes through it: the entries go to a
564
+ # marked block at the end of config.toml, verified by parsing and restored byte for byte.
565
+
566
+ def codex_hooks_json_unsafe(home):
567
+ """Why ~/.codex/hooks.json cannot be edited safely ("symlink" or a reason), or None."""
568
+ path = home / ".codex" / "hooks.json"
569
+ if os.path.islink(path):
570
+ return "symlink"
571
+ try:
572
+ read_regular(protect(path, home))
573
+ except CrowError as exc:
574
+ return str(exc)
575
+ return None
576
+
577
+
578
+ def codex_json_duplicates(home):
579
+ """crow_hook.py entries in ~/.codex/hooks.json when crow-skills does not manage that file
580
+ (read only, through the symlink): with the config.toml block they would run twice."""
581
+ path = home / ".codex" / "hooks.json"
582
+ try:
583
+ if not os.path.exists(path) or os.path.getsize(path) > 1024 * 1024:
584
+ return 0
585
+ with open(path, "r", encoding="utf-8") as handle:
586
+ data = json.load(handle)
587
+ except (OSError, ValueError):
588
+ return 0
589
+ count = 0
590
+ hooks = data.get("hooks") if isinstance(data, dict) else None
591
+ for groups in (hooks.values() if isinstance(hooks, dict) else ()):
592
+ for group in groups if isinstance(groups, list) else []:
593
+ items = group.get("hooks") if isinstance(group, dict) else None
594
+ for item in items if isinstance(items, list) else []:
595
+ if isinstance(item, dict) and HOOK_SCRIPT in str(item.get("command", "")):
596
+ count += 1
597
+ return count
598
+
599
+
600
+ def toml_string(value):
601
+ return json.dumps(value, ensure_ascii=False) # a JSON string is a valid TOML basic string
602
+
603
+
604
+ def codex_hooks_block(entries):
605
+ lines = [HOOKS_BEGIN]
606
+ for event, groups in entries.items():
607
+ for group in groups:
608
+ lines.append("[[hooks." + event + "]]")
609
+ if "matcher" in group:
610
+ lines.append("matcher = " + toml_string(group["matcher"]))
611
+ for item in group["hooks"]:
612
+ lines.append("[[hooks." + event + ".hooks]]")
613
+ for key, value in item.items():
614
+ lines.append(key + " = " + (("true" if value else "false") if isinstance(value, bool) else
615
+ str(value) if isinstance(value, int) else toml_string(value)))
616
+ return "\n".join(lines + [HOOKS_END]) + "\n"
617
+
618
+
619
+ def without_groups(parsed, groups):
620
+ """parsed minus each of groups ({event: [group]}), each removed once by equality; None when
621
+ one of them is not there unchanged (edited, or keys appended to it)."""
622
+ result = deepcopy(parsed)
623
+ hooks = result.get("hooks")
624
+ for event, wanted in groups.items():
625
+ current = hooks.get(event) if isinstance(hooks, dict) else None
626
+ if not isinstance(current, list):
627
+ return None
628
+ for group in wanted:
629
+ if group not in current:
630
+ return None
631
+ del current[len(current) - 1 - current[::-1].index(group)] # the last equal one: ours come after the user's
632
+ if not current:
633
+ del hooks[event]
634
+ return result
635
+
636
+
637
+ def codex_hooks_toml_sync(store, home, record, desired, python, dry, unsafe):
638
+ label = "~/.codex/config.toml"
639
+ if tomllib is None:
640
+ return record, "skip", "Python 3.11+ (tomllib) é necessário para gravar os hooks do Codex em config.toml com verificação."
641
+ directory = home / ".codex"
642
+ if desired and not record and not directory.is_dir():
643
+ return None, "skip", "~/.codex/hooks.json: CLI não configurado neste perfil; nada alterado."
644
+ path = protect(directory / "config.toml", home)
645
+ old = read_regular(path)
646
+ try:
647
+ text = old.decode("utf-8") if old is not None else ""
648
+ except UnicodeDecodeError:
649
+ return record, "skip", "config.toml não é UTF-8; hooks do Codex não configurados."
650
+ span = marked(text, HOOKS_BEGIN, HOOKS_END)
651
+ stripped = text[:span[0]] + text[span[1]:] if span else text
652
+ try:
653
+ current = tomllib.loads(text)
654
+ base = tomllib.loads(stripped)
655
+ except tomllib.TOMLDecodeError:
656
+ return record, "skip", "config.toml não é TOML válido; hooks do Codex não configurados."
657
+ old_block = text[span[0]:span[1]] if span else None
658
+ if span and (not record or record.get("block_sha") != sha(old_block.encode("utf-8"))):
659
+ return (record if desired else None), "skip", ("Bloco codex-hooks do crow-skills em config.toml foi editado ou não tem registro; "
660
+ "nada alterado. Remova o bloco manualmente e rode crow-skills integrations install.")
661
+ if span:
662
+ old_groups = tomllib.loads(old_block).get("hooks", {})
663
+ expected_base = without_groups(current, old_groups)
664
+ if expected_base is not None and "hooks" in expected_base and not expected_base["hooks"] and "hooks" not in base:
665
+ expected_base.pop("hooks")
666
+ if expected_base != base:
667
+ return (record if desired else None), "skip", ("Chaves acrescentadas logo depois do bloco codex-hooks em config.toml mudariam de "
668
+ "tabela; nada alterado. Revise o arquivo manualmente.")
669
+ added_newline = (record or {}).get("added_newline", False)
670
+ if not desired:
671
+ if not record:
672
+ return None, "unchanged", None
673
+ if not span:
674
+ return None, "skip", "Bloco codex-hooks ausente de config.toml; controle encerrado."
675
+ result = stripped
676
+ if added_newline and span[1] == len(text) and result.endswith("\n"):
677
+ result = result[:-1]
678
+ if dry:
679
+ return None, "remove", None
680
+ backup(store, "codex-config", old)
681
+ if record.get("created_file") and not result.strip():
682
+ path.unlink()
683
+ else:
684
+ write(path, result.encode("utf-8"), old)
685
+ return None, "remove", None
686
+ if not quoted_ok(python, hook_script_path(store), home):
687
+ return record, "skip", "Caminho do Python, do estado ou do home contém aspas/variáveis; hooks não configurados."
688
+ entries = hook_entries(store, home, python, "codex")
689
+ block = codex_hooks_block(entries)
690
+ if span:
691
+ if old_block == block:
692
+ report_duplicates(home)
693
+ return record, "unchanged", None
694
+ result = text[:span[0]] + block + text[span[1]:]
695
+ else:
696
+ added_newline = bool(stripped) and not stripped.endswith("\n")
697
+ result = stripped + ("\n" if added_newline else "") + block
698
+ expected = deepcopy(base)
699
+ hooks = expected.setdefault("hooks", {})
700
+ try:
701
+ for event, groups in entries.items():
702
+ if not isinstance(hooks.setdefault(event, []), list):
703
+ raise ValueError(event)
704
+ hooks[event].extend(deepcopy(groups))
705
+ if tomllib.loads(result) != expected:
706
+ raise ValueError("diff")
707
+ except (tomllib.TOMLDecodeError, ValueError, AttributeError):
708
+ return record, "skip", ("A tabela [hooks] de config.toml tem um formato que o bloco do crow-skills não preservaria "
709
+ "(por exemplo, hooks definidos inline); nada alterado.")
710
+ action = "update" if record else "install"
711
+ new_record = {"path": str(path), "mode": "toml", "added_newline": added_newline, "installed_at": now(),
712
+ "block_sha": sha(block.encode("utf-8")),
713
+ "created_file": record.get("created_file", False) if record and record.get("mode") == "toml" else old is None}
714
+ note = ("~/.codex/hooks.json " + ("é symlink" if unsafe == "symlink" else "não pode ser editado com segurança") +
715
+ "; os hooks do crow-skills foram gravados em ~/.codex/config.toml (bloco marcado) e o hooks.json não foi tocado. "
716
+ "Codex só executa hook novo ou alterado depois de aprovado: abra o Codex, rode /hooks e aprove as entradas do "
717
+ "crow-skills.") + codex_hooks_feature(home)
718
+ report_duplicates(home)
719
+ if dry:
720
+ return new_record, action, note.strip()
721
+ new_record["backup"] = backup(store, "codex-config", old)
722
+ write(path, result.encode("utf-8"), old)
723
+ return new_record, action, note.strip()
724
+
725
+
726
+ # Findings that do not stop an integration but need the user (reported in "skipped").
727
+ _WARNINGS = []
728
+
729
+
730
+ def report_duplicates(home):
731
+ note = codex_duplicates_note(home)
732
+ if note:
733
+ _WARNINGS.append({"integration": "codex-hooks", "reason": note})
734
+
735
+
736
+ def codex_duplicates_note(home):
737
+ count = codex_json_duplicates(home)
738
+ if not count:
739
+ return None
740
+ return ("~/.codex/hooks.json (não gerenciado pelo crow-skills) já tem " + str(count) +
741
+ " entrada(s) com crow_hook.py, provavelmente adicionadas à mão; com o bloco em config.toml elas rodariam em dobro. "
742
+ "Remova-as do hooks.json (ou do arquivo para onde o symlink aponta); o crow-skills não edita esse arquivo.")
743
+
744
+
538
745
  # --- /crow slash commands ------------------------------------------------------------------------
539
746
 
540
747
  COMMANDS = {
@@ -612,9 +819,9 @@ def command_sync(name, store, home, record, desired, python, dry):
612
819
 
613
820
  # --- codex ---------------------------------------------------------------------------------
614
821
 
615
- def marked(text):
616
- begins = [m.start() for m in re.finditer(r"(?m)^" + re.escape(BEGIN) + r"\n", text)]
617
- ends = [m.end() for m in re.finditer(r"(?m)^" + re.escape(END) + r"\n", text)]
822
+ def marked(text, begin=BEGIN, end=END):
823
+ begins = [m.start() for m in re.finditer(r"(?m)^" + re.escape(begin) + r"\n", text)]
824
+ ends = [m.end() for m in re.finditer(r"(?m)^" + re.escape(end) + r"\n", text)]
618
825
  if not begins and not ends:
619
826
  return None
620
827
  if len(begins) != 1 or len(ends) != 1 or ends[0] <= begins[0]:
@@ -886,6 +1093,7 @@ def sync(store, home, wanted, dry_run=False, python=None):
886
1093
  after["files"][record["path"]] = {"backup": record.get("backup"), "created_file": record.get("created_file", False),
887
1094
  "written_sha": record["written_sha"]}
888
1095
  global _FILES, _LIVE
1096
+ del _WARNINGS[:]
889
1097
  _FILES = {k: dict(v) for k, v in after["files"].items()}
890
1098
  _LIVE = after["integrations"]
891
1099
  with (nullcontext() if dry_run else store.lock()):
@@ -930,6 +1138,8 @@ def sync(store, home, wanted, dry_run=False, python=None):
930
1138
  if script.exists():
931
1139
  script.unlink()
932
1140
  remove_bytecode(store)
1141
+ report["skipped"].extend(_WARNINGS)
1142
+ del _WARNINGS[:]
933
1143
  for key in ("changes", "skipped"):
934
1144
  report[key].sort(key=lambda c: NAMES.index(c["integration"]) if c["integration"] in NAMES else 99)
935
1145
  report["dry_run"] = dry_run
@@ -946,6 +1156,12 @@ def status(store, home):
946
1156
  row["path"] = record.get("path") or record.get("conf")
947
1157
  if name == "tmux-status" and not record.get("block"):
948
1158
  row["note"] = "tmux.conf é symlink; linha source-file adicionada manualmente pelo usuário."
1159
+ if name == "codex-hooks" and record.get("mode") == "toml":
1160
+ row["note"] = "Gravado em ~/.codex/config.toml (bloco marcado): ~/.codex/hooks.json é symlink ou inseguro e não é editado."
1161
+ duplicates = codex_duplicates_note(home)
1162
+ if duplicates:
1163
+ row["note"] += " " + duplicates
1164
+ row["duplicates"] = codex_json_duplicates(home)
949
1165
  rows.append(row)
950
1166
  for agent, reason in UNSUPPORTED.items():
951
1167
  rows.append({"integration": UNSUPPORTED_ROW[agent], "agents": [agent], "status": "unsupported", "note": reason})