@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 +2 -2
- package/crow_skills/__init__.py +1 -1
- package/crow_skills/autoupdate.py +77 -4
- package/crow_skills/broadcast.py +242 -2
- package/crow_skills/cli.py +121 -4
- package/crow_skills/guide.py +29 -7
- package/crow_skills/hook.py +60 -3
- package/crow_skills/machine.py +79 -4
- package/crow_skills/projection.py +128 -0
- package/crow_skills/resources.py +876 -25
- package/docs/cli.md +56 -3
- package/package.json +1 -1
- package/release.json +28 -18
- package/skills/crow-behaviour/SKILL.md +2 -1
- package/skills/crow-behaviour/references/broadcast.md +2 -0
- package/skills/crow-behaviour/references/processes.md +42 -0
- package/skills/crow-behaviour/references/progress-ledger.md +4 -0
- package/skills/crow-behaviour/references/resources.md +1 -1
- package/skills/crow-behaviour/scripts/crow_ledger.py +619 -3
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.
|
|
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.
|
|
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
|
package/crow_skills/__init__.py
CHANGED
|
@@ -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":
|
|
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
|
|
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",
|
package/crow_skills/broadcast.py
CHANGED
|
@@ -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:
|
package/crow_skills/cli.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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:
|