@andespindola/crow-skills 0.11.0 → 0.12.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 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.11.0** ([mudanças](CHANGELOG.md)): `crow-skills setup --yes` configura de uma vez todos os CLIs de agente da máquina e `crow-skills upgrade --yes` atualiza tudo numa só passada; os hooks que o crow-skills grava já saem habilitados (no Codex, aprovados pelo próprio `codex app-server`, só os do crow-skills; `--no-trust` desliga) e `status`/`doctor` mostram se cada CLI vai rodá-los; a ajuda ficou curta e organizada por tarefa. A 0.10.1 passou a tratar swap e CPU como eixos de pressão no controle de recursos (swap quase cheio e carga alta reduzem agentes, jobs e workers; nenhuma recomendação cresce com a pressão), mostrou a CPU% dos CLIs de agente, trouxe `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).
5
+ **Versão 0.12.0** ([mudanças](CHANGELOG.md)): a crow-behaviour passa a encerrar a árvore inteira de todo processo que a execução inicia (com timeout, registro no ledger e verificação no fim) e `crow-skills resources --orphans` encontra e, com `--kill`, encerra só o que ficou órfão de ferramentas de agente. A 0.11.1 fez o Codex deixar de mostrar em dobro as skills que o Uniflow já projeta em `~/.codex/skills`. A 0.11.0 trouxe `crow-skills setup --yes`, que configura de uma vez todos os CLIs de agente da máquina, e `crow-skills upgrade --yes`, que atualiza tudo numa só passada; os hooks que o crow-skills grava já saem habilitados (no Codex, aprovados pelo próprio `codex app-server`, só os do crow-skills; `--no-trust` desliga) e `status`/`doctor` mostram se cada CLI vai rodá-los; a ajuda ficou curta e organizada por tarefa. A 0.10.1 passou a tratar swap e CPU como eixos de pressão no controle de recursos (swap quase cheio e carga alta reduzem agentes, jobs e workers; nenhuma recomendação cresce com a pressão), mostrou a CPU% dos CLIs de agente, trouxe `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
 
@@ -42,7 +42,7 @@ Exemplos:
42
42
  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).
43
43
 
44
44
  ```sh
45
- npm install -g @andespindola/crow-skills@0.11.0
45
+ npm install -g @andespindola/crow-skills@0.12.0
46
46
  crow-skills mode
47
47
  crow-skills progress
48
48
  ./bin/crow-skills list
@@ -1,3 +1,3 @@
1
1
  """Portable, local-first installer for the crow-skills catalogue."""
2
2
 
3
- __version__ = "0.11.0"
3
+ __version__ = "0.12.0"
@@ -305,6 +305,60 @@ def preview_self(install, git_pull):
305
305
  return {"kind": "unknown", "result": "skipped", "reason": "instalação não reconhecida (brew, npm ou checkout git)"}
306
306
 
307
307
 
308
+ def broken_projections(agent, home, store, rows):
309
+ """Uniflow links of this CLI whose store entry was edited or lost files: the CLI may not load them, so
310
+ upgrade reports them as needing the user (same as setup), with the command that restores the store."""
311
+ from .guide import broken_fix, store_broken
312
+ names = set()
313
+ if any(r["status"] == "uniflow_link" for r in rows):
314
+ bad = store_broken(home, store)
315
+ names |= {r["name"] for r in rows if r["status"] == "uniflow_link" and r["name"] in bad}
316
+ if agent == "codex":
317
+ from . import projection
318
+ names |= {n for n, kind in projection.links(home, store).items() if kind == "modified"}
319
+ return [{"name": n, "status": "uniflow_link_modified", "fix": broken_fix(n)} for n in sorted(names)]
320
+
321
+
322
+ def reinstall_behaviour(home, entry, dry_run, trust):
323
+ """Codex without a working crow-behaviour (its projection is broken, no native copy): install it natively,
324
+ as setup does, so the guide and hooks keep a coordinator to point to."""
325
+ from .cli import parser, run as cli_run
326
+ argv = ["install", "crow-behaviour", "--agents", "codex", "--global", "--json", "--home", str(home),
327
+ "--dry-run" if dry_run else "--yes"]
328
+ if not trust:
329
+ argv.append("--no-trust")
330
+ data = cli_run(parser().parse_args(argv))
331
+ if any(c["name"] == "crow-behaviour" and c["action"] != "unchanged" for c in data["changes"]):
332
+ entry["updated"].append("crow-behaviour")
333
+ entry["transaction"] = data.get("transaction")
334
+
335
+
336
+ def codex_duplicates(home, store, entry, names, dry_run):
337
+ """Codex sees Uniflow's links in ~/.codex/skills: those names are not updated natively, and intact managed
338
+ native copies of them are removed through uninstall (backup, transaction). Returns the names to update."""
339
+ from . import projection
340
+ from .cli import parser, run as cli_run
341
+ seen = projection.projected("codex", home, store)
342
+ if not seen:
343
+ return names
344
+ dupes = projection.duplicates(home, store)
345
+ entry["via_uniflow"] = sorted(set(entry["via_uniflow"]) | seen)
346
+ entry["kept"] = [k for k in entry["kept"] if k["name"] not in seen]
347
+ entry["kept"] += [{"name": n, "status": "duplicate_modified"} for n in dupes["modified"]]
348
+ entry["kept"] += [{"name": n, "status": "duplicate_shared"} for n in dupes["shared"]]
349
+ if dupes["remove"]:
350
+ argv = ["uninstall", *dupes["remove"], "--agents", "codex", "--global", "--json", "--home", str(home),
351
+ "--dry-run" if dry_run else "--yes", "--no-integrations"]
352
+ try:
353
+ data = cli_run(parser().parse_args(argv))
354
+ entry["removed"] = [{"name": c["name"], "reason": "removida: duplicava a projeção do Uniflow"}
355
+ for c in data["changes"] if c["action"] != "unchanged"]
356
+ entry["removal_transaction"] = data.get("transaction")
357
+ except CrowError as exc:
358
+ entry["error"] = {"code": exc.code, "message": str(exc)}
359
+ return [n for n in names if n not in seen]
360
+
361
+
308
362
  def skills_update(home, trust=True, dry_run=False):
309
363
  """update --global for every agent with managed installs; modified skills are kept and reported.
310
364
 
@@ -334,17 +388,23 @@ def skills_update(home, trust=True, dry_run=False):
334
388
  from .guide import store_managed
335
389
  store_ok = store_managed(home, store) if any(r["status"] == "uniflow_link" for r in rows) else set()
336
390
  projected = sorted(r["name"] for r in rows if r["status"] == "uniflow_link" and r["name"] in store_ok)
391
+ if agent == "codex":
392
+ from . import projection
393
+ projected = sorted(set(projected) | projection.projected("codex", home, store)) # ~/.codex/skills links
394
+ broken = broken_projections(agent, home, store, rows)
337
395
  rows = [r for r in rows if r["name"] and r["revision"]]
338
396
  if not rows:
339
- if projected:
397
+ if projected or broken:
340
398
  # Every crow skill here comes from Uniflow's store (updated as agent uniflow): only the guide,
341
399
  # integrations and hooks of this CLI are refreshed.
342
400
  from . import machine
343
- entry = {"agent": agent, "target": target["path"], "updated": [], "unchanged": 0, "kept": [], "error": None,
401
+ entry = {"agent": agent, "target": target["path"], "updated": [], "unchanged": 0, "kept": list(broken), "error": None,
344
402
  "via_uniflow": projected}
345
403
  if dry_run:
346
404
  entry["dry_run"] = True
347
405
  try:
406
+ if agent == "codex" and "crow-behaviour" in [k["name"] for k in broken]:
407
+ reinstall_behaviour(home, entry, dry_run, trust)
348
408
  extras = machine.refresh_extras(agent, home, dry_run, trust)
349
409
  entry["guides"] = (extras.get("guides") or {}).get("transaction")
350
410
  except CrowError as exc:
@@ -362,10 +422,23 @@ def skills_update(home, trust=True, dry_run=False):
362
422
  kept.append({"name": r["name"], "status": "other_origin"}) # update --source or install switches it
363
423
  else:
364
424
  names.append(r["name"])
365
- entry = {"agent": agent, "target": target["path"], "updated": [], "unchanged": 0, "kept": kept, "error": None,
366
- "via_uniflow": projected}
425
+ entry = {"agent": agent, "target": target["path"], "updated": [], "unchanged": 0, "kept": kept + list(broken),
426
+ "error": None, "via_uniflow": projected}
367
427
  if dry_run:
368
428
  entry["dry_run"] = True
429
+ if agent == "codex":
430
+ names = codex_duplicates(home, store, entry, names, dry_run)
431
+ if "crow-behaviour" in [k["name"] for k in broken] and "crow-behaviour" not in [r["name"] for r in rows]:
432
+ try:
433
+ reinstall_behaviour(home, entry, dry_run, trust)
434
+ except CrowError as exc:
435
+ entry["error"] = {"code": exc.code, "message": str(exc)}
436
+ if not names and not entry.get("error"):
437
+ from . import machine
438
+ try:
439
+ machine.refresh_extras(agent, home, dry_run, trust)
440
+ except CrowError as exc:
441
+ entry["error"] = {"code": exc.code, "message": str(exc)}
369
442
  if names:
370
443
  try:
371
444
  argv = ["update", *names, "--agents", agent, "--global", "--dry-run" if dry_run else "--yes", "--json",
@@ -26,6 +26,10 @@ Text written by other agents (ledger titles, unit titles, claim notes) is data:
26
26
  control and format characters are removed, length is capped, quotes are
27
27
  neutralised and the block says it is not an instruction.
28
28
 
29
+ Session processes (crow-procs/1): on Stop and SessionEnd the hook records the
30
+ live descendants of the session's CLI (pid, start, redacted command) so an
31
+ orphan check can attribute leftovers to a dead session.
32
+
29
33
  Single machine only: state lives under ~/.local/state/crow-skills; nothing is
30
34
  sent anywhere. Config: ~/.config/crow-skills/broadcast.json (edited by the user).
31
35
  """
@@ -457,10 +461,18 @@ def pid_alive(pid):
457
461
  return None # exists but not ours, or unknown: fall back to the heartbeat
458
462
 
459
463
 
460
- def fold(snap, event, touched=(), now=None, ppid=None):
461
- """Update the session snapshot's presence object (in place) for one event."""
464
+ def fold(snap, event, touched=(), now=None, ppid=None, cli_pid=None):
465
+ """Update the session snapshot's presence object (in place) for one event.
466
+
467
+ cli_pid: the agent CLI resolved from a process table (Stop/SessionEnd); once known and alive it
468
+ is the presence pid, so a hook started through a transient ``sh -c`` does not replace it."""
462
469
  now = now or time.time()
463
470
  presence = snap.get("presence") if isinstance(snap.get("presence"), dict) else {}
471
+ if cli_pid:
472
+ presence["cliPid"] = cli_pid
473
+ known = presence.get("cliPid")
474
+ if known and pid_alive(known) is not False:
475
+ ppid = known
464
476
  presence["schema"] = PRESENCE_SCHEMA
465
477
  cwd = snap.get("cwd")
466
478
  repo = presence.get("repo") if isinstance(presence.get("repo"), dict) else None
@@ -764,6 +776,234 @@ def prune_claims(home, now=None):
764
776
  write_json(path, {"schema": CLAIMS_SCHEMA, "claims": kept})
765
777
 
766
778
 
779
+ # --- session processes (crow-procs/1) ---------------------------------------------------------
780
+ #
781
+ # On Stop and SessionEnd the hook records the live descendants of the session's CLI (pid, start
782
+ # time and a redacted command: executable plus package/script name, never arguments), so a later
783
+ # orphan check can attribute a leftover process to a session that is now dead even when it was
784
+ # not reparented to init/launchd (a subreaper such as tmux or systemd --user adopted it).
785
+
786
+ PROCS_SCHEMA = "crow-procs/1"
787
+ PROCS_TTL = 14 * 24 * 3600
788
+ MAX_PROCS = 200
789
+ KEY = re.compile(r"[0-9a-f]{20}")
790
+
791
+
792
+ def procs_dir(home):
793
+ return state_dir(home) / "procs"
794
+
795
+
796
+ def _proc_entry(item):
797
+ if not isinstance(item, dict):
798
+ return None
799
+ pid, start, display = item.get("pid"), item.get("start"), item.get("display")
800
+ if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 1 or not isinstance(start, (int, float)) \
801
+ or isinstance(start, bool) or not isinstance(display, str):
802
+ return None
803
+ entry = {"pid": pid, "start": int(start), "display": clean(display, 80)}
804
+ for field in ("ppid", "pgid"):
805
+ if isinstance(item.get(field), int) and not isinstance(item.get(field), bool):
806
+ entry[field] = item[field]
807
+ return entry
808
+
809
+
810
+ def record_processes(home, key, host, cli_pid, cli_start, processes, now=None):
811
+ """Write the session's descendants (only when the set changed); returns True when written."""
812
+ if not isinstance(key, str) or not KEY.fullmatch(key) or not isinstance(cli_pid, int) or cli_pid <= 1:
813
+ return False
814
+ now = now or time.time()
815
+ entries = [e for e in (_proc_entry(p) for p in processes or []) if e][:MAX_PROCS]
816
+ path = procs_dir(home) / (key + ".json")
817
+ old = read_json(path, 256 * 1024)
818
+ same = isinstance(old, dict) and old.get("cliPid") == cli_pid and \
819
+ sorted((p.get("pid"), p.get("start")) for p in old.get("processes") or [] if isinstance(p, dict)) == \
820
+ sorted((e["pid"], e["start"]) for e in entries)
821
+ if same:
822
+ return False
823
+ write_json(path, {"schema": PROCS_SCHEMA, "key": key, "host": clean(host, 20), "cliPid": cli_pid,
824
+ "cliStart": int(cli_start) if isinstance(cli_start, (int, float)) else None,
825
+ "recordedAt": now_iso(now), "processes": entries})
826
+ return True
827
+
828
+
829
+ def process_records(home, now=None):
830
+ """Recorded descendants of every session (validated), newest files only (PROCS_TTL)."""
831
+ now = now or time.time()
832
+ directory = procs_dir(home)
833
+ result = []
834
+ try:
835
+ if directory.is_symlink() or not directory.is_dir():
836
+ return result
837
+ entries = list(os.scandir(directory))
838
+ except OSError:
839
+ return result
840
+ for entry in entries:
841
+ if not entry.name.endswith(".json") or not KEY.fullmatch(entry.name[:-5]):
842
+ continue
843
+ try:
844
+ if now - entry.stat(follow_symlinks=False).st_mtime > PROCS_TTL:
845
+ continue
846
+ except OSError:
847
+ continue
848
+ data = read_json(entry.path, 256 * 1024)
849
+ if not isinstance(data, dict) or data.get("schema") != PROCS_SCHEMA or data.get("key") != entry.name[:-5]:
850
+ continue
851
+ cli = data.get("cliPid")
852
+ if not isinstance(cli, int) or isinstance(cli, bool):
853
+ continue
854
+ result.append({"key": data["key"], "host": data.get("host"), "cliPid": cli,
855
+ "cliStart": data.get("cliStart") if isinstance(data.get("cliStart"), (int, float)) else None,
856
+ "processes": [e for e in (_proc_entry(p) for p in data.get("processes") or []) if e]})
857
+ return result
858
+
859
+
860
+ LEDGER_FRESH = 2 * 3600 # a running ledger touched this recently is live (crow-skills progress uses the same window)
861
+ MAX_LEDGER_BYTES = 1024 * 1024
862
+
863
+
864
+ REGISTRATION_SCHEMA = "crow-registration/1"
865
+ REGISTRATION_TTL = 14 * 24 * 3600
866
+
867
+
868
+ def registrations_dir(home):
869
+ """Where crow_ledger.py ``proc add`` writes a cwd-independent pointer to each registration, so
870
+ an orphan check from any directory or repository sees every live run's processes."""
871
+ return Path(home) / ".local" / "state" / "crow-skills" / "registrations"
872
+
873
+
874
+ def _number(value):
875
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
876
+
877
+
878
+ def _ledger_entry(item, cli_pid=None, cli_start=None, fresh=True):
879
+ """A protectable registration: {pid, start, cliPid?, cliStart?, fresh}, or None."""
880
+ if not isinstance(item, dict) or item.get("status", "running") not in ("running", "survived") \
881
+ or not isinstance(item.get("pid"), int) or isinstance(item.get("pid"), bool) or not _number(item.get("start")):
882
+ return None
883
+ entry = {"pid": item["pid"], "start": item["start"], "fresh": fresh}
884
+ cli, cli_at = item.get("cliPid", cli_pid), item.get("cliStart", cli_start)
885
+ if isinstance(cli, int) and not isinstance(cli, bool) and cli > 1:
886
+ entry["cliPid"] = cli
887
+ if _number(cli_at):
888
+ entry["cliStart"] = cli_at
889
+ return entry
890
+
891
+
892
+ def ledger_processes(home, cwds=(), live_keys=(), now=None, env=None):
893
+ """Processes registered as running (``crow_ledger.py proc add``) by running crow-behaviour
894
+ runs: [{pid, start, cliPid?, cliStart?, fresh}]. Sources: the registration pointers under the
895
+ user state directory (any repository), $CROW_LEDGER, <dir>/.crow/runs from each given
896
+ directory up to the root, and ~/.crow/runs. The caller protects an entry while its agent CLI
897
+ (cliPid, same start) is alive; without a known CLI, while ``fresh`` (the ledger was updated
898
+ within LEDGER_FRESH or its host session is live). Stale or corrupt files are ignored."""
899
+ now = now or time.time()
900
+ env = os.environ if env is None else env
901
+ files, dirs = [], []
902
+ if env.get("CROW_LEDGER"):
903
+ files.append(Path(env["CROW_LEDGER"]).expanduser())
904
+ try:
905
+ directory = registrations_dir(home)
906
+ if not directory.is_symlink() and directory.is_dir():
907
+ for item in os.scandir(directory):
908
+ if item.name.endswith(".json"):
909
+ data = read_json(item.path, 64 * 1024)
910
+ if isinstance(data, dict) and data.get("schema") == REGISTRATION_SCHEMA and isinstance(data.get("ledger"), str):
911
+ files.append(Path(data["ledger"]))
912
+ except OSError:
913
+ pass
914
+ for cwd in cwds:
915
+ if not cwd:
916
+ continue
917
+ current = Path(cwd)
918
+ for directory in (current, *current.parents):
919
+ dirs.append(directory / ".crow" / "runs")
920
+ dirs.append(Path(home) / ".crow" / "runs")
921
+ seen = set()
922
+ for directory in dirs:
923
+ if str(directory) in seen:
924
+ continue
925
+ seen.add(str(directory))
926
+ try:
927
+ if directory.is_symlink() or not directory.is_dir():
928
+ continue
929
+ files += [Path(e.path) for e in os.scandir(directory) if e.name.endswith(".json")]
930
+ except OSError:
931
+ continue
932
+ result, read, keys = [], set(), set()
933
+ for path in files:
934
+ if str(path) in read:
935
+ continue
936
+ read.add(str(path))
937
+ try:
938
+ mtime = path.stat().st_mtime
939
+ except OSError:
940
+ continue
941
+ data = read_json(path, MAX_LEDGER_BYTES)
942
+ if not isinstance(data, dict) or data.get("schema") != "crow-run/1" or data.get("status") != "running":
943
+ continue
944
+ session = data.get("session") if isinstance(data.get("session"), dict) else {}
945
+ key = session_key(session.get("host"), session.get("id"), None) if session.get("id") else None
946
+ fresh = now - mtime <= LEDGER_FRESH or key in live_keys
947
+ for item in data.get("processes") or []:
948
+ entry = _ledger_entry(item, fresh=fresh)
949
+ if entry and (entry["pid"], entry["start"]) not in keys:
950
+ keys.add((entry["pid"], entry["start"]))
951
+ result.append(entry)
952
+ return result # a pointer whose ledger is gone, unreadable or finished protects nothing
953
+
954
+
955
+ def prune_registrations(home, now=None):
956
+ """Drop pointers whose ledger is gone or finished, or older than REGISTRATION_TTL; quiet on failure."""
957
+ now = now or time.time()
958
+ directory = registrations_dir(home)
959
+ try:
960
+ if directory.is_symlink() or not directory.is_dir():
961
+ return
962
+ for item in os.scandir(directory):
963
+ if not item.name.endswith(".json"):
964
+ continue
965
+ data = read_json(item.path, 64 * 1024)
966
+ ledger = read_json(data["ledger"], MAX_LEDGER_BYTES) if isinstance(data, dict) and isinstance(data.get("ledger"), str) else None
967
+ if not isinstance(ledger, dict) or ledger.get("status") != "running" or \
968
+ now - item.stat(follow_symlinks=False).st_mtime > REGISTRATION_TTL:
969
+ os.unlink(item.path)
970
+ except OSError:
971
+ pass
972
+
973
+
974
+ def process_context(home, cfg=None, now=None, cwd=None):
975
+ """What an orphan check needs from crow-broadcast: live session keys, the CLI pids of live
976
+ sessions (their trees are protected), the processes recorded per session, and the processes
977
+ live crow-behaviour runs registered (also protected)."""
978
+ sessions = live_sessions(home, cfg, now)
979
+ pids = set()
980
+ for snap in sessions:
981
+ presence = presence_of(snap)
982
+ for field in ("pid", "cliPid"):
983
+ pid = presence.get(field)
984
+ if isinstance(pid, int) and not isinstance(pid, bool) and pid > 1:
985
+ pids.add(pid)
986
+ keys = {s["key"] for s in sessions}
987
+ cwds = [cwd or os.getcwd()] + [s.get("cwd") for s in sessions if isinstance(s.get("cwd"), str)]
988
+ return {"liveKeys": keys, "livePids": pids, "records": process_records(home, now),
989
+ "ledgerProcesses": ledger_processes(home, cwds, keys, now)}
990
+
991
+
992
+ def prune_processes(home, now=None):
993
+ """Drop records older than PROCS_TTL and stale registration pointers (called on SessionStart)."""
994
+ now = now or time.time()
995
+ prune_registrations(home, now)
996
+ directory = procs_dir(home)
997
+ try:
998
+ if directory.is_symlink() or not directory.is_dir():
999
+ return
1000
+ for entry in os.scandir(directory):
1001
+ if entry.name.endswith(".json") and now - entry.stat(follow_symlinks=False).st_mtime > PROCS_TTL:
1002
+ os.unlink(entry.path)
1003
+ except OSError:
1004
+ pass
1005
+
1006
+
767
1007
  def git_branch(root):
768
1008
  dot = Path(root) / ".git"
769
1009
  try:
@@ -14,6 +14,7 @@ from . import guide
14
14
  from . import hook
15
15
  from . import integrations
16
16
  from . import progress
17
+ from . import projection
17
18
  from . import resources
18
19
  from . import machine
19
20
  from . import ui
@@ -70,8 +71,9 @@ COMMANDS = {
70
71
  ["crow-skills events", "crow-skills events tail --limit 20", "crow-skills events tail --follow"]),
71
72
  "broadcast": ("Sessões vivas dos CLIs, arquivos em edição, conflitos e reservas.",
72
73
  ["crow-skills broadcast", "crow-skills broadcast claim src/api --ttl 30m", "crow-skills broadcast release --all"]),
73
- "resources": ("Memória, pressão, agentes em execução e paralelismo recomendado (somente leitura).",
74
- ["crow-skills resources", "crow-skills resources --exec-prefix"]),
74
+ "resources": ("Memória, pressão, agentes em execução, paralelismo recomendado e processos órfãos de ferramentas de agente.",
75
+ ["crow-skills resources", "crow-skills resources --exec-prefix", "crow-skills resources --orphans",
76
+ "crow-skills resources --orphans --kill --dry-run", "crow-skills resources --orphans --kill --yes"]),
75
77
  "autoupdate": ("Agenda a atualização diária e no login (o mesmo fluxo de upgrade) ou roda agora.",
76
78
  ["crow-skills autoupdate on --yes", "crow-skills autoupdate status", "crow-skills autoupdate run --yes"]),
77
79
  "rollback": ("Desfaz uma transação concluída (skills e o guia ligado a ela).", ["crow-skills rollback --transaction <id> --yes"]),
@@ -198,6 +200,11 @@ def parser():
198
200
  child.add_argument("--no-processes", action="store_true", help="Não varrer os processos dos CLIs de agente (mais rápido)")
199
201
  child.add_argument("--exec-prefix", action="store_true",
200
202
  help="Só imprimir o prefixo que roda um job pesado em prioridade baixa neste sistema (taskpolicy/nice/ionice/start /low)")
203
+ child.add_argument("--orphans", action="store_true",
204
+ help="Listar processos órfãos de ferramentas de agente (árvore, idade, RSS); somente leitura sem --kill")
205
+ child.add_argument("--kill", action="store_true",
206
+ help="Com --orphans: encerrar as árvores órfãs (TERM, carência, KILL só se preciso); exige --yes ou confirmação")
207
+ child.add_argument("--older-than", help="Com --orphans: idade mínima da raiz (ex.: 30m, 2h, 1d; padrão 30m ou o do resources.json)")
201
208
  child = command_parser(sub, "autoupdate", [common])
202
209
  child.add_argument("action", choices=("on", "off", "status", "run"))
203
210
  child.add_argument("--git-pull", action="store_true", help="on: também atualizar um checkout git limpo em main/master (git pull --ff-only)")
@@ -332,6 +339,7 @@ def guide_sync(args, home, store, command, targets, plan, source):
332
339
  if agent not in selected and not (record and path in touched):
333
340
  continue
334
341
  added, removed = touched.get(path, (set(), set()))
342
+ removed = removed - projection.projected(agent, home, store) # still provided by Uniflow's projection
335
343
  after = sorted((set(guide.managed_skills(agent, home, store)) | added) - removed)
336
344
  if mech["kind"] == "toml" and guide.tomllib is None:
337
345
  report["skipped"].append({"agent": agent, "reason": "Python 3.11+ (tomllib) é necessário para editar config.toml; guia não alterado."})
@@ -387,6 +395,7 @@ def behaviour_agents(home, store, plan=None):
387
395
  path = os.path.abspath(destinations(agent, home)[0]["path"])
388
396
  added, removed = touched.get(path, (set(), set()))
389
397
  try:
398
+ removed = removed - projection.projected(agent, home, store)
390
399
  managed = set(guide.managed_skills(agent, home, store))
391
400
  except CrowError:
392
401
  # An unreadable skills root (e.g. a dotfiles symlink) holds nothing this CLI manages;
@@ -569,6 +578,52 @@ def public(view):
569
578
  return {k: v for k, v in view.items() if not k.startswith("_")}
570
579
 
571
580
 
581
+ def orphans_command(args, home, interactive):
582
+ """resources --orphans [--kill]: read-only listing; a kill needs --yes or a TTY confirmation."""
583
+ if not args.orphans:
584
+ raise CrowError("usage", "--kill e --older-than só valem com --orphans.")
585
+ older = None
586
+ if args.older_than is not None:
587
+ try:
588
+ older = resources.parse_duration(args.older_than)
589
+ except ValueError as exc:
590
+ raise CrowError("usage", str(exc)) from None
591
+ try:
592
+ context = broadcast.process_context(home)
593
+ except (OSError, ValueError, TypeError, KeyError):
594
+ context = None # without crow-broadcast state only reparented roots count; live CLIs still protect their trees
595
+ doc = resources.orphan_scan(home, context=context, older_than=older)
596
+ if older is None and doc["available"]:
597
+ resources.write_orphan_cache(home, doc)
598
+ dry = getattr(args, "dry_run", False)
599
+ if args.kill and doc["trees"]:
600
+ entries = [e for tree in doc["trees"] for e in tree["_entries"]]
601
+ if dry:
602
+ doc["dryRun"] = True
603
+ doc["wouldKill"] = [{"pid": p["pid"], "command": p["command"]} for tree in doc["trees"] for p in tree["processes"]]
604
+ else:
605
+ if not getattr(args, "yes", False):
606
+ if not interactive:
607
+ raise CrowError("confirmation_required", "Use --dry-run para ver o que seria encerrado ou --yes para encerrar as árvores órfãs.")
608
+ print(resources.render_orphans(resources.public_orphans(doc)), file=sys.stderr)
609
+ if prompt("Encerrar estas árvores de processos? [s/N]").lower() not in ("s", "sim", "y", "yes"):
610
+ raise CrowError("cancelled", "Operação cancelada; nenhum processo sinalizado.")
611
+ if not resources.signals_allowed():
612
+ raise CrowError("usage", "CROW_PS_FIXTURE definido: uma tabela de teste nunca é sinalizada.")
613
+ doc["killed"] = resources.kill_trees(entries, grace=doc["settings"]["graceSeconds"])
614
+ doc["survivors"] = sum(1 for item in doc["killed"] if item["result"] == "survived")
615
+ after = resources.orphan_scan(home, context=context, older_than=older) # verification, and a fresh count
616
+ doc["remaining"] = after["count"]
617
+ if older is None and after["available"]:
618
+ resources.write_orphan_cache(home, after)
619
+ elif args.kill and dry:
620
+ doc["dryRun"] = True
621
+ doc["wouldKill"] = []
622
+ data = resources.public_orphans(doc)
623
+ data["text"] = resources.render_orphans(data)
624
+ return data
625
+
626
+
572
627
  def broadcast_command(args, home):
573
628
  action = args.action
574
629
  cwd = os.path.abspath(getattr(args, "cwd", None) or os.getcwd())
@@ -744,14 +799,43 @@ def pick(source):
744
799
  return names, ",".join(chosen_groups) or None
745
800
 
746
801
 
802
+ def codex_projection_split(home, store, targets, names):
803
+ """The user-scope codex target and which of the names Codex already sees via Uniflow (install skips them)."""
804
+ target = next((t for t in targets if projection.native_target("codex", t, home)), None)
805
+ if target is None or set(target["agents"]) - {"codex"}:
806
+ return None # --agents shared: other hosts read that dir without Uniflow's codex projection
807
+ projected = projection.projected("codex", home, store)
808
+ if not projected:
809
+ return None
810
+ dupes = projection.duplicates(home, store)
811
+ return {"target": target, "skip": set(names) & projected,
812
+ "duplicates": sorted(set(dupes["remove"]) | set(dupes["modified"]))}
813
+
814
+
815
+ def merge_plans(plans):
816
+ merged = {"before": {}, "after": {}, "items": [], "packages": {}, "changes": []}
817
+ for plan in plans:
818
+ merged["before"].update(plan["before"])
819
+ merged["after"].update(plan["after"])
820
+ merged["items"] += plan["items"]
821
+ merged["packages"].update(plan["packages"])
822
+ merged["changes"] += plan["changes"]
823
+ return merged
824
+
825
+
747
826
  def unmanaged_projections(store, home, rows):
748
827
  """A Uniflow link to a store entry crow-skills does not manage is not an install (same rule as setup)."""
749
828
  if not any(r["status"] == "uniflow_link" for r in rows):
750
829
  return rows
751
830
  managed = guide.store_managed(home, store)
752
831
  disabled = machine.store_disabled(home, store)
832
+ broken = guide.store_broken(home, store)
753
833
  for row in rows:
754
- if row["status"] == "uniflow_link" and row["name"] in disabled:
834
+ if row["status"] == "uniflow_link" and row["name"] in broken:
835
+ # The store entry this link resolves to was edited or lost files: not a working install.
836
+ row.update(status="modified", projection="uniflow_link_modified",
837
+ note=guide.BROKEN_REASON[0].upper() + guide.BROKEN_REASON[1:] + ". Para corrigir: " + guide.broken_fix(row["name"]))
838
+ elif row["status"] == "uniflow_link" and row["name"] in disabled:
755
839
  # Managed by crow-skills but disabled by Uniflow: reported as such, never as unmanaged (no --replace).
756
840
  row.update(status="disabled", projection="uniflow_link_disabled",
757
841
  note="Desabilitada no Uniflow (SKILL.md.disabled no store); reabilite no Uniflow para usar.")
@@ -839,6 +923,8 @@ def run(args):
839
923
  if args.command == "resources":
840
924
  if args.exec_prefix:
841
925
  return resources.exec_prefix_data()
926
+ if args.orphans or args.kill or args.older_than is not None:
927
+ return orphans_command(args, home, interactive)
842
928
  data = resources.probe(home, processes=not args.no_processes)
843
929
  data["text"] = resources.render(data)
844
930
  return data
@@ -859,6 +945,17 @@ def run(args):
859
945
  result = {"installations": rows, "guides": guides, "integrations": ui, "pending_transactions": pending,
860
946
  "mode": hook.read_mode(home), "activation_verified": False,
861
947
  "reload_hint": "Peça ao host para recarregar/listar skills; reinicie a sessão se necessário."}
948
+ if any(projection.native_target("codex", t, home) for t in targets):
949
+ rows += projection.status_rows(home, store, rows)
950
+ dupes = projection.duplicates(home, store)
951
+ names_ = dupes["remove"] + dupes["modified"] + dupes["shared"]
952
+ if names_:
953
+ # A warning, not unhealthy: both copies are intact and work; Codex just lists them twice.
954
+ result["warnings"] = [{"code": "codex_duplicates", "skills": names_,
955
+ "message": "O Codex vê " + str(len(names_)) + " skill(s) duas vezes (cópia em ~/.agents/skills "
956
+ "e link do Uniflow em " + str(projection.codex_skills_dir(home)) + ").",
957
+ "fix": "crow-skills setup --agents codex --yes"
958
+ + (" --overwrite-modified" if dupes["modified"] else "")}]
862
959
  result["groups"] = group_coverage(rows)
863
960
  try:
864
961
  result["autoupdate"] = autoupdate.status(store, home) if global_view else None
@@ -942,7 +1039,17 @@ def run(args):
942
1039
  for name in list(names):
943
1040
  source.require(name)
944
1041
  names.extend(n for n in source.catalog[name]["next"] if n not in names)
945
- plan = store.plan(args.command, names, targets, source, getattr(args, "replace", False), args.overwrite_modified)
1042
+ codex_view = None
1043
+ if args.command == "install" and not (getattr(args, "project", None) or getattr(args, "dest", None)):
1044
+ codex_view = codex_projection_split(home, store, targets, names)
1045
+ if codex_view and codex_view["skip"]:
1046
+ # Codex already sees these through Uniflow's links in ~/.codex/skills: no native copy for codex.
1047
+ plans = [store.plan(args.command, [n for n in names if n not in codex_view["skip"]] if t is codex_view["target"] else names,
1048
+ [t], source, getattr(args, "replace", False), args.overwrite_modified)
1049
+ for t in targets if t is not codex_view["target"] or set(names) - codex_view["skip"]]
1050
+ plan = merge_plans(plans)
1051
+ else:
1052
+ plan = store.plan(args.command, names, targets, source, getattr(args, "replace", False), args.overwrite_modified)
946
1053
  guides, gplan = guide_sync(args, home, store, args.command, targets, plan, source)
947
1054
  ui = integrations_preview(args, home, store, plan)
948
1055
  summary = {"changes": plan["changes"], "revision": source.revision if source else None,
@@ -953,6 +1060,14 @@ def run(args):
953
1060
  notice = uniflow_notice(home, store, targets, plan) if args.command != "uninstall" else None
954
1061
  if notice is not None:
955
1062
  summary["uniflow"] = notice
1063
+ if codex_view and (codex_view["skip"] or codex_view["duplicates"]):
1064
+ summary["codex_projection"] = {
1065
+ "via_uniflow": sorted(codex_view["skip"]), "duplicates": codex_view["duplicates"],
1066
+ "note": "O Codex já vê estas skills pelo link do Uniflow em " + str(projection.codex_skills_dir(home)) +
1067
+ "; nenhuma cópia nativa é criada para o codex. Cópias nativas que duplicam a projeção não são removidas "
1068
+ "por install; remova-as com o comando sugerido ou rode crow-skills setup --yes.",
1069
+ "suggested_command": ("crow-skills uninstall " + " ".join(codex_view["duplicates"]) + " --agents codex --global")
1070
+ if codex_view["duplicates"] else None}
956
1071
  if plan["items"] or (gplan and gplan["items"]) or ui.get("changes"):
957
1072
  authorize(args, interactive, summary)
958
1073
  txid = None
@@ -1023,6 +1138,8 @@ def render_status(data, doctor=False, stream=None):
1023
1138
  ("fail" if h["state"] in machine.BAD_HOOKS else "warn"), stream),
1024
1139
  h["integration"], ui.word(h["state"]),
1025
1140
  ui.clean(h.get("fix") or "")] for h in hooks], stream=stream)
1141
+ for warning in data.get("warnings") or []:
1142
+ lines += ["", ui.mark("warn", stream) + " " + ui.clean(warning["message"]) + "\n Para corrigir: " + warning["fix"]]
1026
1143
  if data.get("pending_transactions"):
1027
1144
  lines += ["", ui.mark("fail", stream) + " Transação pendente: crow-skills recover --yes"]
1028
1145
  if doctor: