@andespindola/crow-skills 0.8.0 → 0.9.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.8.0** ([mudanças](CHANGELOG.md)): `crow-skills mode on|off` 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.9.0** ([mudanças](CHANGELOG.md)): as skills controlam a 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.8.0
33
+ npm install -g @andespindola/crow-skills@0.9.0
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.8.0"
3
+ __version__ = "0.9.0"
@@ -12,6 +12,7 @@ from . import guide
12
12
  from . import hook
13
13
  from . import integrations
14
14
  from . import progress
15
+ from . import resources
15
16
  from .agents import AGENTS, destinations, inventory
16
17
  from .source import CrowError, Source, valid_name
17
18
  from .storage import Store, checked
@@ -77,6 +78,9 @@ def parser():
77
78
  child.add_argument("action", nargs="?", default="snapshot", choices=("snapshot", "tail"))
78
79
  child.add_argument("--limit", type=int, default=50, help="tail: quantos eventos (padrão 50; 0 = todos os retidos)")
79
80
  child.add_argument("--follow", action="store_true", help="tail: transmitir eventos novos em JSONL até Ctrl-C")
81
+ child = sub.add_parser("resources", parents=[common],
82
+ help="Memória, pressão, agentes em execução e paralelismo recomendado (somente leitura)")
83
+ child.add_argument("--no-processes", action="store_true", help="Não varrer os processos dos CLIs de agente (mais rápido)")
80
84
  child = sub.add_parser("autoupdate", parents=[common], help="Atualização diária e no login do crow-skills e das skills gerenciadas")
81
85
  child.add_argument("action", choices=("on", "off", "status", "run"))
82
86
  child.add_argument("--git-pull", action="store_true", help="on: também atualizar um checkout git limpo em main/master (git pull --ff-only)")
@@ -460,6 +464,10 @@ def run(args):
460
464
  return mode_command(args, home)
461
465
  if args.command == "events":
462
466
  return events_command(args, home, store)
467
+ if args.command == "resources":
468
+ data = resources.probe(home, processes=not args.no_processes)
469
+ data["text"] = resources.render(data)
470
+ return data
463
471
  if args.command == "autoupdate":
464
472
  return autoupdate_command(args, home, store, interactive)
465
473
  if args.command in ("status", "doctor"):
@@ -586,6 +594,8 @@ def emit(command, data, machine):
586
594
  elif command == "progress":
587
595
  if data is not None:
588
596
  print(data["text"] or "crow: nenhuma execução registrada neste diretório.")
597
+ elif command == "resources":
598
+ print(data["text"])
589
599
  elif command == "mode":
590
600
  print(data["indicator"] + " — " + data["applies"])
591
601
  elif command == "events":
@@ -11,7 +11,7 @@ import json
11
11
  import sys
12
12
  import time
13
13
 
14
- from . import autoupdate, hook, progress
14
+ from . import autoupdate, hook, progress, resources
15
15
  from .source import CrowError
16
16
 
17
17
  SNAPSHOT_SCHEMA = "crow-snapshot/1"
@@ -29,7 +29,7 @@ def ledger_runs(home, cwds):
29
29
  runs.append({"id": run["id"], "title": run["title"], "host": run["host"], "cwd": run["cwd"], "status": run["status"],
30
30
  "session": run["session"].get("id"), "path": run["path"], "updatedAt": run.get("updatedAt"),
31
31
  "done": info["done"], "failed": info["failed"], "total": info["total"], "running": info["running"],
32
- "progress": info["progress"],
32
+ "progress": info["progress"], "resources": run.get("resources"),
33
33
  "agents": [{"owner": progress.owner_label(u["owner"]), "unit": u["id"], "activity": u["title"]}
34
34
  for u in run["units"] if u["status"] == "running"]})
35
35
  return runs
@@ -54,9 +54,25 @@ def snapshot(home, store):
54
54
  except CrowError as exc:
55
55
  update = {"enabled": None, "error": exc.code}
56
56
  mode = hook.read_mode(home)
57
+ try:
58
+ machine = resources_field(home)
59
+ except Exception: # noqa: BLE001 - the snapshot never fails because of the probe
60
+ machine = None
57
61
  return {"schema": SNAPSHOT_SCHEMA, "generatedAt": datetime.now(timezone.utc).isoformat(timespec="seconds"),
58
62
  "mode": {k: mode[k] for k in ("mode", "updatedAt", "updatedBy", "source")}, "indicator": hook.indicator(mode["mode"]),
59
- "sessions": live, "runs": runs, "autoupdate": update}
63
+ "sessions": live, "runs": runs, "autoupdate": update, "resources": machine}
64
+
65
+
66
+ def resources_field(home):
67
+ """crow-resources/1 subset for terminals: memory, pressure and the recommendation.
68
+
69
+ Added in 0.9.0 without changing crow-snapshot/1: consumers that do not know the
70
+ field ignore it; the field carries its own schema for future changes.
71
+ """
72
+ data = resources.probe(home, processes=False, use_cache=True)
73
+ return {"schema": data["schema"], "generatedAt": data["generatedAt"], "cached": data["cached"],
74
+ "memory": data["memory"], "pressure": {"level": data["pressure"]["level"], "reasons": data["pressure"]["reasons"]},
75
+ "recommendation": data["recommendation"], "alert": resources.alert(data), "segment": resources.segment(data)}
60
76
 
61
77
 
62
78
  def render(data, unicode=True):
@@ -75,6 +91,12 @@ def render(data, unicode=True):
75
91
  unicode, counts=False)
76
92
  lines.append(("✓ " if unicode else "ok ") + str(run["done"]) + "/" + str(run["total"]) + " " + run["title"])
77
93
  update = data["autoupdate"]
94
+ machine = data.get("resources")
95
+ if machine and machine.get("alert"):
96
+ rec = machine["recommendation"]
97
+ lines.append("")
98
+ lines.append("memória: " + (machine["segment"] or "") + " · pressão " + machine["pressure"]["level"] + " · " + rec["mode"] +
99
+ " (até " + str(rec["maxParallelAgents"]) + " agente(s), " + str(rec["maxParallelHeavyJobs"]) + " job(s) pesado(s))")
78
100
  lines.append("")
79
101
  lines.append("autoupdate: " + ("ligado" if update.get("enabled") else "desligado") +
80
102
  ((" · última execução " + str(update["lastRun"]["startedAt"]) + (" ok" if update["lastRun"]["ok"] else " com falha"))
@@ -13,6 +13,11 @@ permission mode, event type, tool name, counters and timestamps.
13
13
 
14
14
  crow_hook.py hook --host claude|codex|gemini|agy|devin --event NAME [--home DIR]
15
15
  crow_hook.py mode on|off|status [--home DIR] [--by HOST] [--json]
16
+
17
+ On a prompt event the hook also appends one memory line to the context, only
18
+ when the machine is under pressure or the recommended parallelism is not
19
+ "parallel" (crow_resources.py, copied beside this file; cached for a few
20
+ seconds, so most prompts cost one small file read).
16
21
  """
17
22
  from datetime import datetime, timezone
18
23
  import hashlib
@@ -152,6 +157,53 @@ def context_line(mode):
152
157
  "explicitamente neste pedido. Religar: /crow on ou crow-skills mode on.")
153
158
 
154
159
 
160
+ # --- machine resources ------------------------------------------------------------------
161
+
162
+ _RESOURCES = None
163
+
164
+
165
+ def resources_module():
166
+ """The resource probe: the package module, or crow_resources.py beside this file.
167
+
168
+ Loaded by path because the hooks run with ``python -I`` (the script's directory is
169
+ not on sys.path). None when it is missing: the hook then adds nothing.
170
+ """
171
+ global _RESOURCES
172
+ if _RESOURCES is None:
173
+ _RESOURCES = False
174
+ try:
175
+ if __package__:
176
+ from . import resources as module # type: ignore[attr-defined]
177
+ _RESOURCES = module
178
+ else:
179
+ import importlib.util
180
+ here = Path(os.path.abspath(__file__)).parent
181
+ for name in ("crow_resources.py", "resources.py"):
182
+ path = here / name
183
+ if path.is_file() and not path.is_symlink():
184
+ spec = importlib.util.spec_from_file_location("crow_resources", path)
185
+ module = importlib.util.module_from_spec(spec)
186
+ # Bytecode is cached in __pycache__ beside it (~5 ms less per prompt);
187
+ # the integrations uninstall removes that directory with the scripts.
188
+ spec.loader.exec_module(module)
189
+ _RESOURCES = module
190
+ break
191
+ except Exception: # noqa: BLE001 - a broken probe never breaks the hook
192
+ _RESOURCES = False
193
+ return _RESOURCES or None
194
+
195
+
196
+ def resource_hint(home):
197
+ """The one-line memory hint, or None (no pressure, no probe, or any failure)."""
198
+ module = resources_module()
199
+ if module is None:
200
+ return None
201
+ try:
202
+ return module.hint(module.probe(home, processes=False, use_cache=True))
203
+ except Exception: # noqa: BLE001
204
+ return None
205
+
206
+
155
207
  # --- event bus ----------------------------------------------------------------------------
156
208
 
157
209
  def append_event(home, event):
@@ -472,7 +524,11 @@ def handle(host, raw, payload, home, env=None):
472
524
  update_session(home, dict(stamped, event="other"), {"modeSeen": stamp, "mode": current["mode"]})
473
525
  if raw == "SessionStart":
474
526
  return output_for(host, raw, system=system) if system else None
475
- return output_for(host, raw, context=context_line(current["mode"]), system=system)
527
+ context = context_line(current["mode"])
528
+ memory = resource_hint(home)
529
+ if memory:
530
+ context += "\n" + memory
531
+ return output_for(host, raw, context=context, system=system)
476
532
 
477
533
 
478
534
  def update_metrics(home, host, payload):
@@ -55,6 +55,7 @@ OWNERS = {"claude-statusline": ("claude",), "claude-hooks": ("claude",), "claude
55
55
  "gemini-hooks": ("gemini",), "gemini-command": ("gemini",), "agy-hooks": ("gemini",), "agy-statusline": ("gemini",),
56
56
  "devin-hooks": ("devin",), "tmux-status": ("claude", "codex", "gemini", "devin")}
57
57
  HOOK_SCRIPT = "crow_hook.py"
58
+ RESOURCES_SCRIPT = "crow_resources.py"
58
59
  COMMAND_HEADER = "Gerenciado por crow-skills (comando /crow). Não edite; use crow-skills integrations."
59
60
  BEGIN = "# >>> crow-skills integrations (gerenciado; use crow-skills integrations uninstall) >>>"
60
61
  END = "# <<< crow-skills integrations <<<"
@@ -117,6 +118,37 @@ def hook_source():
117
118
  return (Path(__file__).resolve().parent / "hook.py").read_bytes()
118
119
 
119
120
 
121
+ def resources_script_path(store):
122
+ return root(store) / RESOURCES_SCRIPT
123
+
124
+
125
+ def resources_source():
126
+ return (Path(__file__).resolve().parent / "resources.py").read_bytes()
127
+
128
+
129
+ def remove_bytecode(store):
130
+ """__pycache__ beside the copied scripts (written when the status line imports crow_hook):
131
+ removed with them, only when it holds nothing but their bytecode."""
132
+ cache = root(store) / "__pycache__"
133
+ if cache.is_symlink() or not cache.is_dir():
134
+ return
135
+ names = ("crow_progress.", "crow_hook.", "crow_resources.")
136
+ entries = list(cache.iterdir())
137
+ if all(e.is_file() and not e.is_symlink() and e.name.startswith(names) and e.suffix == ".pyc" for e in entries):
138
+ try:
139
+ for entry in entries:
140
+ entry.unlink()
141
+ cache.rmdir()
142
+ except OSError:
143
+ pass # a hook wrote bytecode meanwhile: the directory stays, nothing else is touched
144
+
145
+
146
+ def scripts(store):
147
+ """(installed path, source bytes) of every script the hooks and status lines run."""
148
+ return ((script_path(store), script_source()), (hook_script_path(store), hook_source()),
149
+ (resources_script_path(store), resources_source()))
150
+
151
+
120
152
  def quoted_ok(*paths):
121
153
  return all(not re.search(r"[\"'\\$`\n]", str(p)) for p in paths)
122
154
 
@@ -825,7 +857,7 @@ def sync(store, home, wanted, dry_run=False, python=None):
825
857
  with (nullcontext() if dry_run else store.lock()):
826
858
  if not dry_run and any(set(OWNERS[n]) & set(wanted) for n in NAMES):
827
859
  private_dir(root(store))
828
- for script, source in ((script_path(store), script_source()), (hook_script_path(store), hook_source())):
860
+ for script, source in scripts(store):
829
861
  if not script.exists() or sha(script.read_bytes()) != sha(source):
830
862
  write_atomic(checked(script), source, 0o644)
831
863
  # Removals first and in reverse order, then installs in order: two integrations that
@@ -859,9 +891,10 @@ def sync(store, home, wanted, dry_run=False, python=None):
859
891
  if after != state:
860
892
  atomic_json(state_path(store), after)
861
893
  if not after["integrations"]:
862
- for script in (script_path(store), hook_script_path(store)):
894
+ for script in (script_path(store), hook_script_path(store), resources_script_path(store)):
863
895
  if script.exists():
864
896
  script.unlink()
897
+ remove_bytecode(store)
865
898
  for key in ("changes", "skipped"):
866
899
  report[key].sort(key=lambda c: NAMES.index(c["integration"]) if c["integration"] in NAMES else 99)
867
900
  report["dry_run"] = dry_run
@@ -83,7 +83,7 @@ def validate(data):
83
83
  "status": data.get("status") if data.get("status") in ("running",) + FINISHED else "running",
84
84
  "startedAt": data.get("startedAt"), "updatedAt": data.get("updatedAt"), "endedAt": data.get("endedAt"),
85
85
  "session": data.get("session") if isinstance(data.get("session"), dict) else {},
86
- "phases": [], "units": []}
86
+ "phases": [], "units": [], "resources": resources_decision(data.get("resources"))}
87
87
  for phase in phases:
88
88
  if isinstance(phase, dict) and isinstance(phase.get("id"), str):
89
89
  run["phases"].append({"id": phase["id"], "title": safe(phase.get("title") or phase["id"]),
@@ -107,6 +107,42 @@ def validate(data):
107
107
  return run
108
108
 
109
109
 
110
+ RESOURCE_MODES = ("parallel", "reduced", "sequential")
111
+ RESOURCE_LEVELS = ("normal", "warn", "critical", "unknown")
112
+ RESOURCE_SOURCES = ("probe", "os", "assumed")
113
+
114
+
115
+ def resources_decision(value):
116
+ """The optional run-level ``resources`` field (0.9.0): the parallelism decision recorded
117
+ before a fan-out. Ledgers without it, or with an invalid one, read as None."""
118
+ if not isinstance(value, dict) or value.get("mode") not in RESOURCE_MODES:
119
+ return None
120
+
121
+ def count(key):
122
+ number = value.get(key)
123
+ return number if isinstance(number, int) and not isinstance(number, bool) and 0 <= number <= 256 else None
124
+
125
+ return {"mode": value["mode"], "pressure": value.get("pressure") if value.get("pressure") in RESOURCE_LEVELS else None,
126
+ "source": value.get("source") if value.get("source") in RESOURCE_SOURCES else None,
127
+ "maxParallelAgents": count("maxParallelAgents"), "maxParallelHeavyJobs": count("maxParallelHeavyJobs"),
128
+ "maxWorkersPerJob": count("maxWorkersPerJob"),
129
+ "checkedAt": value.get("checkedAt") if isinstance(value.get("checkedAt"), str) else None,
130
+ "note": safe(value.get("note") or "", 160)}
131
+
132
+
133
+ def resources_line(decision, unicode=True):
134
+ if not decision:
135
+ return ""
136
+ parts = [decision["mode"]]
137
+ if decision["maxParallelAgents"] is not None:
138
+ parts.append(("≤" if unicode else "<=") + str(decision["maxParallelAgents"]) + " agente(s)")
139
+ if decision["maxParallelHeavyJobs"] is not None:
140
+ parts.append(("≤" if unicode else "<=") + str(decision["maxParallelHeavyJobs"]) + " job(s) pesado(s)")
141
+ if decision["pressure"]:
142
+ parts.append("pressão " + decision["pressure"])
143
+ return "recursos: " + (" · " if unicode else " - ").join(parts) + (" (" + decision["note"] + ")" if decision["note"] else "")
144
+
145
+
110
146
  def load(path):
111
147
  path = Path(path)
112
148
  if path.is_symlink() or not path.is_file() or path.stat().st_size > MAX_LEDGER:
@@ -515,6 +551,8 @@ def render(run, usage=None, now=None, color=False, unicode=True, width=20):
515
551
  bar(info["progress"], width, unicode) + " " + str(int(info["progress"] * 100)).rjust(3) + "% " +
516
552
  str(info["done"]) + "/" + str(info["total"]) + " unidades" +
517
553
  (" - " + str(info["failed"]) + " falha(s)" if info["failed"] else "") + " " + token_text(usage, unicode)]
554
+ if run.get("resources"):
555
+ lines.append(paint("dim", resources_line(run["resources"], unicode)))
518
556
  lines += ["", *agent_rows(run, unicode, counts=False), ""]
519
557
  phase_ids = [p["id"] for p in run["phases"]]
520
558
  groups = [(p, [u for u in run["units"] if u["phase"] == p["id"]]) for p in run["phases"]]
@@ -649,6 +687,18 @@ def mode_text(home, unicode=True):
649
687
  return ""
650
688
 
651
689
 
690
+ def memory_text(home, unicode=True):
691
+ """'mem ▲ 85% ×2' when the machine is under pressure or parallelism is reduced; else ''."""
692
+ hook = hook_module()
693
+ module = hook.resources_module() if hook is not None and hasattr(hook, "resources_module") else None
694
+ if module is None:
695
+ return ""
696
+ try:
697
+ return module.segment(module.probe(home, processes=False, use_cache=True), unicode)
698
+ except Exception: # noqa: BLE001 - the probe never breaks a status line
699
+ return ""
700
+
701
+
652
702
  def statusline(home, stdin_text, chain=None, host="claude"):
653
703
  """Status line for Claude Code and Antigravity (agy).
654
704
 
@@ -684,7 +734,7 @@ def statusline(home, stdin_text, chain=None, host="claude"):
684
734
  except (OSError, subprocess.SubprocessError):
685
735
  previous = ""
686
736
  run = select(discover(cwd, home), cwd, strict=True, session=session)
687
- parts = [p for p in (mode_text(home),) if p]
737
+ parts = [p for p in (mode_text(home), memory_text(home)) if p]
688
738
  rows = []
689
739
  if active(run):
690
740
  if run["host"] == "claude" and host == "claude" and not run["session"].get("id") and session:
@@ -711,12 +761,12 @@ def statusline(home, stdin_text, chain=None, host="claude"):
711
761
 
712
762
 
713
763
  def tmux_segment(home, cwd):
714
- mode = mode_text(home)
764
+ head = " · ".join(p for p in (mode_text(home), memory_text(home)) if p)
715
765
  run = select(discover(cwd, home), cwd, strict=True)
716
766
  if not active(run):
717
- return mode
767
+ return head.replace("#", "##")
718
768
  text = compact(run, tokens(run, home), unicode=True, width=8)
719
- return ((mode + " · " if mode else "") + text).replace("#", "##")
769
+ return ((head + " · " if head else "") + text).replace("#", "##")
720
770
 
721
771
 
722
772
  def main(argv=None):