@andespindola/crow-skills 0.8.0 → 0.10.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.
@@ -13,6 +13,20 @@ 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).
21
+
22
+ crow-broadcast (crow_broadcast.py, copied beside this file): every event also
23
+ keeps the session's presence (git repository, CLI pid, PATHS of files its edit
24
+ tools target) in the snapshot; a prompt event appends a short digest of other
25
+ live sessions in the same repository, only when there are any; ``--check``
26
+ (a synchronous PreToolUse entry for edit and shell tools) warns about another
27
+ live session on the same file or clone without writing anything.
28
+
29
+ crow_hook.py hook --host HOST --event PreToolUse --check [--home DIR]
16
30
  """
17
31
  from datetime import datetime, timezone
18
32
  import hashlib
@@ -152,6 +166,81 @@ def context_line(mode):
152
166
  "explicitamente neste pedido. Religar: /crow on ou crow-skills mode on.")
153
167
 
154
168
 
169
+ # --- machine resources ------------------------------------------------------------------
170
+
171
+ _RESOURCES = None
172
+
173
+
174
+ def resources_module():
175
+ """The resource probe: the package module, or crow_resources.py beside this file.
176
+
177
+ Loaded by path because the hooks run with ``python -I`` (the script's directory is
178
+ not on sys.path). None when it is missing: the hook then adds nothing.
179
+ """
180
+ global _RESOURCES
181
+ if _RESOURCES is None:
182
+ _RESOURCES = False
183
+ try:
184
+ if __package__:
185
+ from . import resources as module # type: ignore[attr-defined]
186
+ _RESOURCES = module
187
+ else:
188
+ import importlib.util
189
+ here = Path(os.path.abspath(__file__)).parent
190
+ for name in ("crow_resources.py", "resources.py"):
191
+ path = here / name
192
+ if path.is_file() and not path.is_symlink():
193
+ spec = importlib.util.spec_from_file_location("crow_resources", path)
194
+ module = importlib.util.module_from_spec(spec)
195
+ # Bytecode is cached in __pycache__ beside it (~5 ms less per prompt);
196
+ # the integrations uninstall removes that directory with the scripts.
197
+ spec.loader.exec_module(module)
198
+ _RESOURCES = module
199
+ break
200
+ except Exception: # noqa: BLE001 - a broken probe never breaks the hook
201
+ _RESOURCES = False
202
+ return _RESOURCES or None
203
+
204
+
205
+ _BROADCAST = None
206
+
207
+
208
+ def broadcast_module():
209
+ """crow-broadcast: the package module, or crow_broadcast.py beside this file; None when missing."""
210
+ global _BROADCAST
211
+ if _BROADCAST is None:
212
+ _BROADCAST = False
213
+ try:
214
+ if __package__:
215
+ from . import broadcast as module # type: ignore[attr-defined]
216
+ _BROADCAST = module
217
+ else:
218
+ import importlib.util
219
+ here = Path(os.path.abspath(__file__)).parent
220
+ for name in ("crow_broadcast.py", "broadcast.py"):
221
+ path = here / name
222
+ if path.is_file() and not path.is_symlink():
223
+ spec = importlib.util.spec_from_file_location("crow_broadcast", path)
224
+ module = importlib.util.module_from_spec(spec)
225
+ spec.loader.exec_module(module)
226
+ _BROADCAST = module
227
+ break
228
+ except Exception: # noqa: BLE001 - a broken module never breaks the hook
229
+ _BROADCAST = False
230
+ return _BROADCAST or None
231
+
232
+
233
+ def resource_hint(home):
234
+ """The one-line memory hint, or None (no pressure, no probe, or any failure)."""
235
+ module = resources_module()
236
+ if module is None:
237
+ return None
238
+ try:
239
+ return module.hint(module.probe(home, processes=False, use_cache=True))
240
+ except Exception: # noqa: BLE001
241
+ return None
242
+
243
+
155
244
  # --- event bus ----------------------------------------------------------------------------
156
245
 
157
246
  def append_event(home, event):
@@ -225,8 +314,11 @@ def git_branch(cwd):
225
314
  return None
226
315
 
227
316
 
228
- def update_session(home, event, extra=None, fallback_cwd=None):
229
- """Fold one event into the per-session snapshot (state, counters, last action)."""
317
+ def update_session(home, event, extra=None, fallback_cwd=None, presence=None):
318
+ """Fold one event into the per-session snapshot (state, counters, last action).
319
+
320
+ presence: (broadcast module, touched paths, parent pid) to fold the crow-broadcast
321
+ presence in the same locked write; a failure there never loses the event."""
230
322
  directory = no_follow_dir(events_dir(home) / "sessions")
231
323
  key = session_key(event.get("host"), event.get("session"), event.get("cwd"))
232
324
  path = directory / (key + ".json")
@@ -280,6 +372,12 @@ def update_session(home, event, extra=None, fallback_cwd=None):
280
372
  snap["updatedAt"] = event.get("ts") or now_iso()
281
373
  if extra:
282
374
  snap.update(extra)
375
+ if presence:
376
+ module, touched, ppid = presence
377
+ try:
378
+ module.fold(snap, event, touched, ppid=ppid)
379
+ except Exception: # noqa: BLE001 - presence is best effort
380
+ pass
283
381
  write_json(path, snap)
284
382
  return snap
285
383
  finally:
@@ -300,13 +398,13 @@ def prune_sessions(home, now=None):
300
398
  pass
301
399
 
302
400
 
303
- def record(home, fields, snapshot=False, fallback_cwd=None):
401
+ def record(home, fields, snapshot=False, fallback_cwd=None, presence=None):
304
402
  """Stamp and append an event; optionally fold it into the session snapshot."""
305
403
  event = {"v": 1, "schema": EVENT_SCHEMA, "ts": now_iso()}
306
404
  event.update({k: v for k, v in fields.items() if v is not None})
307
405
  append_event(home, event)
308
406
  if snapshot:
309
- return event, update_session(home, event, fallback_cwd=fallback_cwd)
407
+ return event, update_session(home, event, fallback_cwd=fallback_cwd, presence=presence)
310
408
  return event, None
311
409
 
312
410
 
@@ -328,7 +426,7 @@ def sessions(home, live_only=True, now=None):
328
426
  continue
329
427
  snap["ageSeconds"] = int(age)
330
428
  result.append(snap)
331
- return sorted(result, key=lambda s: s.get("updatedAt") or "", reverse=True)
429
+ return sorted(result, key=lambda s: str(s.get("updatedAt") or ""), reverse=True)
332
430
 
333
431
 
334
432
  def tail(home, limit=50):
@@ -384,14 +482,26 @@ def foreign(host, payload, env, home):
384
482
  return False
385
483
 
386
484
 
485
+ def tool_call(payload):
486
+ """Antigravity sends the tool as {"toolCall": {"name", "args"}}."""
487
+ call = payload.get("toolCall")
488
+ return call if isinstance(call, dict) else {}
489
+
490
+
491
+ def workspace(payload):
492
+ """Antigravity runs hooks from the hooks.json directory and sends the workspace as workspacePaths."""
493
+ paths = payload.get("workspacePaths")
494
+ return paths[0] if isinstance(paths, list) and paths and isinstance(paths[0], str) else None
495
+
496
+
387
497
  def normalise(host, raw, payload):
388
498
  kind = EVENTS.get(raw, "other")
389
- tool = token(first(payload, "tool_name", "toolName", "tool"), 64)
499
+ tool = token(first(payload, "tool_name", "toolName", "tool") or tool_call(payload).get("name"), 64)
390
500
  tool_input = payload.get("tool_input") if isinstance(payload.get("tool_input"), dict) else {}
391
501
  event = {
392
502
  "host": host, "event": kind, "raw": token(raw, 40),
393
503
  "session": token(first(payload, "session_id", "sessionId", "conversation_id", "conversationId", "thread_id"), 128),
394
- "cwd": plain_path(first(payload, "cwd", "workspace_root", "workspaceRoot")),
504
+ "cwd": plain_path(first(payload, "cwd", "workspace_root", "workspaceRoot") or workspace(payload)),
395
505
  "model": token(first(payload, "model", "model_name", "modelName"), 80) if not isinstance(payload.get("model"), dict)
396
506
  else token(payload["model"].get("id") or payload["model"].get("display_name"), 80),
397
507
  "permissionMode": token(first(payload, "permission_mode", "permissionMode", "approval_policy", "approvalMode"), 40),
@@ -443,11 +553,107 @@ def output_for(host, raw, context=None, system=None, block=None):
443
553
  return out or None
444
554
 
445
555
 
446
- def handle(host, raw, payload, home, env=None):
556
+ TOOL_START_EVENTS = ("PreToolUse", "BeforeTool")
557
+ SHELL_NAMES = frozenset(("Bash", "shell", "exec_command", "local_shell", "run_shell_command", "run_command", "exec", "bash"))
558
+ # Edit and shell tool names of every host (crow_broadcast.EDIT_TOOLS and SHELL_TOOLS; a test keeps
559
+ # them equal): only these tool events load the broadcast module.
560
+ BROADCAST_TOOLS = frozenset((
561
+ "Edit", "Write", "MultiEdit", "NotebookEdit", "apply_patch", "write_file", "replace", "code_action", "file_change",
562
+ "propose_code", "edit_notebook", "write_blob", "write_to_file", "replace_file_content", "multi_replace_file_content",
563
+ "edit", "write", "notebook_edit",
564
+ "Bash", "shell", "exec_command", "local_shell", "run_shell_command", "run_command", "exec", "bash"))
565
+ # Hosts whose tool-start hook is synchronous in our install (its output is read): the warning goes
566
+ # out from the same call. Claude Code and Codex record asynchronously and get a separate --check entry.
567
+ SYNC_TOOL_HOSTS = ("gemini", "agy", "devin")
568
+
569
+
570
+ def tool_input_of(payload):
571
+ args = tool_call(payload).get("args")
572
+ if isinstance(args, dict):
573
+ return args
574
+ for key in ("tool_input", "toolInput", "input", "tool_args", "arguments", "args"):
575
+ value = payload.get(key)
576
+ if isinstance(value, dict):
577
+ return value
578
+ if isinstance(value, str) and key in ("arguments", "tool_input") and value[:1] == "{" and len(value) < MAX_STDIN:
579
+ try:
580
+ parsed = json.loads(value)
581
+ except ValueError:
582
+ continue
583
+ if isinstance(parsed, dict):
584
+ return parsed
585
+ return {}
586
+
587
+
588
+ # Where a warning can reach the model before/after a tool, per host (checked on the installed
589
+ # versions, see docs/broadcast.md): Claude Code and Codex read additionalContext on PreToolUse;
590
+ # Gemini CLI and Devin only on AfterTool/PostToolUse (their pre-tool hook can only block);
591
+ # Antigravity's PreToolUse reads only a decision (the next PreInvocation digest carries the warning).
592
+ PRE_CONTEXT_HOSTS = ("claude", "codex")
593
+ POST_CONTEXT_HOSTS = ("gemini", "devin")
594
+ POST_TOOL_EVENTS = ("PostToolUse", "AfterTool")
595
+
596
+
597
+ def tool_output(host, raw, text, block=False):
598
+ """Tool-hook warning (model-visible where the host reads it) or, with block, a refusal."""
599
+ if block:
600
+ if host == "claude":
601
+ return {"hookSpecificOutput": {"hookEventName": raw, "permissionDecision": "deny", "permissionDecisionReason": text,
602
+ "additionalContext": text}}
603
+ if host in ("agy", "gemini"):
604
+ return {"decision": "deny", "reason": text}
605
+ return {"decision": "block", "reason": text}
606
+ if raw in TOOL_START_EVENTS and host not in PRE_CONTEXT_HOSTS:
607
+ return None
608
+ return {"hookSpecificOutput": {"hookEventName": raw, "additionalContext": text}, "systemMessage": text.split("\n", 1)[0]}
609
+
610
+
611
+ def broadcast_check(host, raw, home, module, me, touched, tree):
612
+ """Run the conflict check; returns the host output or None. Never raises."""
613
+ try:
614
+ cfg = module.config(home)
615
+ pre = raw in TOOL_START_EVENTS
616
+ block = pre and bool(cfg["block_on_conflict"])
617
+ if pre and not block and host not in PRE_CONTEXT_HOSTS:
618
+ return None # nothing this host would show before the tool; the post-tool or prompt hook warns
619
+ text, conflict = module.check(home, (me or {}).get("key"), me, touched, tree, cfg=cfg)
620
+ if not text:
621
+ return None
622
+ return tool_output(host, raw, text, block=block and conflict)
623
+ except Exception: # noqa: BLE001
624
+ return None
625
+
626
+
627
+ def check_only(host, raw, payload, home):
628
+ """--check: read-only conflict check before an edit/shell tool (no event, no snapshot write)."""
629
+ tool = first(payload, "tool_name", "toolName", "tool") or tool_call(payload).get("name")
630
+ shell_input = tool_input_of(payload)
631
+ command = shell_input.get("command") or shell_input.get("cmd") or shell_input.get("CommandLine") or shell_input.get("commandLine")
632
+ if tool in SHELL_NAMES and "git" not in json.dumps(command or ""):
633
+ return None # a shell command without git cannot switch the clone's branch: nothing to load
634
+ module = broadcast_module()
635
+ if module is None or raw not in TOOL_START_EVENTS:
636
+ return None
637
+ event = normalise(host, raw, payload)
638
+ cwd = event.get("cwd") or process_cwd()
639
+ tool_input = tool_input_of(payload)
640
+ touched = module.edit_paths(host, event.get("tool"), tool_input, cwd)
641
+ tree = module.tree_command(host, event.get("tool"), tool_input, cwd)
642
+ if not touched and not tree:
643
+ return None
644
+ key = session_key(host, event.get("session"), event.get("cwd"))
645
+ me = read_json(events_dir(home) / "sessions" / (key + ".json")) or {}
646
+ me = dict(me, key=key, cwd=me.get("cwd") or cwd)
647
+ return broadcast_check(host, raw, home, module, me, touched, tree)
648
+
649
+
650
+ def handle(host, raw, payload, home, env=None, check=False):
447
651
  """Process one hook call; returns the JSON to print (or None). Never raises to the caller."""
448
652
  env = os.environ if env is None else env
449
653
  if host not in HOSTS or not isinstance(payload, dict) or foreign(host, payload, env, home):
450
654
  return None
655
+ if check:
656
+ return check_only(host, raw, payload, home)
451
657
  event = normalise(host, raw, payload)
452
658
  current = read_mode(home)
453
659
  if raw in PROMPT_EVENTS:
@@ -460,7 +666,49 @@ def handle(host, raw, payload, home, env=None):
460
666
  current = write_mode(home, wanted, host)
461
667
  record(home, dict(event, event="mode_command", mode=current["mode"]), snapshot=True, fallback_cwd=process_cwd())
462
668
  return output_for(host, raw, block=indicator(current["mode"]) + " (crow-skills; vale para todos os CLIs a partir do próximo prompt)")
463
- stamped, snap = record(home, dict(event, mode=current["mode"]), snapshot=True, fallback_cwd=process_cwd())
669
+ # The broadcast module is loaded only where it has work: prompts and session start (repo,
670
+ # pid, digest) and tool events of edit/shell tools (paths, checks). Other events keep the
671
+ # 0.9.0 cost; the snapshot's updatedAt is the heartbeat.
672
+ wanted = raw in PROMPT_EVENTS or raw == "SessionStart" or \
673
+ (event["event"] in ("tool_start", "tool_end") and event.get("tool") in BROADCAST_TOOLS)
674
+ module, presence, touched, tree, checked = broadcast_module() if wanted else None, None, [], [], []
675
+ if module is not None:
676
+ try:
677
+ cfg = module.config(home)
678
+ if cfg["enabled"]:
679
+ if event["event"] == "tool_start":
680
+ tool_input = tool_input_of(payload)
681
+ cwd = event.get("cwd") or process_cwd()
682
+ touched = module.edit_paths(host, event.get("tool"), tool_input, cwd)
683
+ checked = list(touched)
684
+ tree = module.tree_command(host, event.get("tool"), tool_input, cwd)
685
+ if touched and cfg["block_on_conflict"]:
686
+ # An edit the conflict check is about to refuse is not published as this
687
+ # session's: otherwise the session that edited first would be locked out too.
688
+ key = session_key(host, event.get("session"), event.get("cwd"))
689
+ mine = read_json(events_dir(home) / "sessions" / (key + ".json")) or {}
690
+ mine = dict(mine, key=key, cwd=mine.get("cwd") or cwd)
691
+ if module.check(home, key, mine, touched, (), cfg=cfg)[1]:
692
+ touched = []
693
+ presence = (module, touched, os.getppid())
694
+ else:
695
+ module = None
696
+ except Exception: # noqa: BLE001 - the broadcast never breaks the event
697
+ module, presence = None, None
698
+ stamped, snap = record(home, dict(event, mode=current["mode"]), snapshot=True, fallback_cwd=process_cwd(), presence=presence)
699
+ if module is not None and host in SYNC_TOOL_HOSTS:
700
+ if raw in TOOL_START_EVENTS and (touched or tree or checked):
701
+ return broadcast_check(host, raw, home, module, snap, checked or touched, tree)
702
+ if raw in POST_TOOL_EVENTS and host in POST_CONTEXT_HOSTS:
703
+ try:
704
+ tool_input = tool_input_of(payload)
705
+ cwd = event.get("cwd") or process_cwd()
706
+ after = module.edit_paths(host, event.get("tool"), tool_input, cwd)
707
+ tree_after = module.tree_command(host, event.get("tool"), tool_input, cwd)
708
+ except Exception: # noqa: BLE001
709
+ after, tree_after = [], []
710
+ if after or tree_after:
711
+ return broadcast_check(host, raw, home, module, snap, after, tree_after)
464
712
  if raw not in PROMPT_EVENTS and raw != "SessionStart":
465
713
  return None
466
714
  system = None
@@ -472,7 +720,18 @@ def handle(host, raw, payload, home, env=None):
472
720
  update_session(home, dict(stamped, event="other"), {"modeSeen": stamp, "mode": current["mode"]})
473
721
  if raw == "SessionStart":
474
722
  return output_for(host, raw, system=system) if system else None
475
- return output_for(host, raw, context=context_line(current["mode"]), system=system)
723
+ context = context_line(current["mode"])
724
+ memory = resource_hint(home)
725
+ if memory:
726
+ context += "\n" + memory
727
+ if module is not None and snap is not None:
728
+ try:
729
+ others = module.digest(home, snap.get("key"), me=snap)
730
+ except Exception: # noqa: BLE001
731
+ others = None
732
+ if others:
733
+ context += "\n" + others
734
+ return output_for(host, raw, context=context, system=system)
476
735
 
477
736
 
478
737
  def update_metrics(home, host, payload):
@@ -515,7 +774,36 @@ def update_metrics(home, host, payload):
515
774
 
516
775
  # --- entry point --------------------------------------------------------------------------
517
776
 
777
+ def fast_hook_args(argv):
778
+ """The hook's own argv parsed without argparse (~5 ms per call); None when it is anything else."""
779
+ if not argv or argv[0] != "hook":
780
+ return None
781
+ values, flags, i = {}, set(), 1
782
+ while i < len(argv):
783
+ name = argv[i]
784
+ if name in ("--host", "--event", "--home") and i + 1 < len(argv):
785
+ values[name[2:]] = argv[i + 1]
786
+ i += 2
787
+ elif name == "--check":
788
+ flags.add("check")
789
+ i += 1
790
+ else:
791
+ return None
792
+ if "host" not in values or "event" not in values:
793
+ return None
794
+ import types
795
+ return types.SimpleNamespace(command="hook", host=values["host"], event=values["event"], home=values.get("home"),
796
+ check="check" in flags)
797
+
798
+
518
799
  def main(argv=None):
800
+ args = fast_hook_args(list(sys.argv[1:] if argv is None else argv))
801
+ if args is None:
802
+ args = parse_args(argv)
803
+ return run_main(args)
804
+
805
+
806
+ def parse_args(argv):
519
807
  import argparse
520
808
  p = argparse.ArgumentParser(prog="crow_hook", description="Hook e modo do crow-skills.")
521
809
  sub = p.add_subparsers(dest="command", required=True)
@@ -523,12 +811,16 @@ def main(argv=None):
523
811
  hook.add_argument("--host", required=True)
524
812
  hook.add_argument("--event", required=True)
525
813
  hook.add_argument("--home")
814
+ hook.add_argument("--check", action="store_true")
526
815
  mode = sub.add_parser("mode")
527
816
  mode.add_argument("action", nargs="?", default="status", choices=("on", "off", "status", "toggle"))
528
817
  mode.add_argument("--home")
529
818
  mode.add_argument("--by")
530
819
  mode.add_argument("--json", action="store_true")
531
- args = p.parse_args(argv)
820
+ return p.parse_args(argv)
821
+
822
+
823
+ def run_main(args):
532
824
  home = Path(args.home).expanduser() if args.home else Path.home()
533
825
  if args.command == "mode":
534
826
  current = read_mode(home)
@@ -546,11 +838,18 @@ def main(argv=None):
546
838
  try:
547
839
  raw = sys.stdin.buffer.read(MAX_STDIN + 1) if not sys.stdin.isatty() else b""
548
840
  payload = json.loads(raw.decode("utf-8")) if raw.strip() and len(raw) <= MAX_STDIN else {}
549
- result = handle(args.host, args.event, payload, home)
550
- if args.event == "SessionStart":
551
- prune_sessions(home)
841
+ result = handle(args.host, args.event, payload, home, check=args.check)
552
842
  if result:
553
843
  sys.stdout.write(json.dumps(result, ensure_ascii=False) + "\n")
844
+ sys.stdout.flush()
845
+ if args.event == "SessionStart" and not args.check:
846
+ prune_sessions(home)
847
+ module = broadcast_module()
848
+ if module is not None:
849
+ try:
850
+ module.prune_claims(home)
851
+ except Exception: # noqa: BLE001 - pruning is housekeeping; the output already went out
852
+ pass
554
853
  except Exception: # noqa: BLE001 - a hook must never break the agent
555
854
  return 0
556
855
  return 0
@@ -55,6 +55,8 @@ 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"
59
+ BROADCAST_SCRIPT = "crow_broadcast.py"
58
60
  COMMAND_HEADER = "Gerenciado por crow-skills (comando /crow). Não edite; use crow-skills integrations."
59
61
  BEGIN = "# >>> crow-skills integrations (gerenciado; use crow-skills integrations uninstall) >>>"
60
62
  END = "# <<< crow-skills integrations <<<"
@@ -117,6 +119,45 @@ def hook_source():
117
119
  return (Path(__file__).resolve().parent / "hook.py").read_bytes()
118
120
 
119
121
 
122
+ def resources_script_path(store):
123
+ return root(store) / RESOURCES_SCRIPT
124
+
125
+
126
+ def resources_source():
127
+ return (Path(__file__).resolve().parent / "resources.py").read_bytes()
128
+
129
+
130
+ def broadcast_script_path(store):
131
+ return root(store) / BROADCAST_SCRIPT
132
+
133
+
134
+ def broadcast_source():
135
+ return (Path(__file__).resolve().parent / "broadcast.py").read_bytes()
136
+
137
+
138
+ def remove_bytecode(store):
139
+ """__pycache__ beside the copied scripts (written when the status line imports crow_hook):
140
+ removed with them, only when it holds nothing but their bytecode."""
141
+ cache = root(store) / "__pycache__"
142
+ if cache.is_symlink() or not cache.is_dir():
143
+ return
144
+ names = ("crow_progress.", "crow_hook.", "crow_resources.", "crow_broadcast.")
145
+ entries = list(cache.iterdir())
146
+ if all(e.is_file() and not e.is_symlink() and e.name.startswith(names) and e.suffix == ".pyc" for e in entries):
147
+ try:
148
+ for entry in entries:
149
+ entry.unlink()
150
+ cache.rmdir()
151
+ except OSError:
152
+ pass # a hook wrote bytecode meanwhile: the directory stays, nothing else is touched
153
+
154
+
155
+ def scripts(store):
156
+ """(installed path, source bytes) of every script the hooks and status lines run."""
157
+ return ((script_path(store), script_source()), (hook_script_path(store), hook_source()),
158
+ (resources_script_path(store), resources_source()), (broadcast_script_path(store), broadcast_source()))
159
+
160
+
120
161
  def quoted_ok(*paths):
121
162
  return all(not re.search(r"[\"'\\$`\n]", str(p)) for p in paths)
122
163
 
@@ -333,37 +374,57 @@ def codex_hooks_feature(home):
333
374
  return " Habilite também [features] hooks = true em ~/.codex/config.toml (não alterado pelo crow-skills)."
334
375
 
335
376
 
336
- def hook_command(store, home, python, host, event):
377
+ def hook_command(store, home, python, host, event, check=False):
337
378
  return (shlex.quote(str(python)) + " -I -S " + shlex.quote(str(hook_script_path(store))) + " hook --host " + host +
338
- " --event " + event + " --home " + shlex.quote(str(home)))
379
+ " --event " + event + (" --check" if check else "") + " --home " + shlex.quote(str(home)))
380
+
381
+
382
+ # crow-broadcast: Claude Code and Codex record tool events asynchronously (their output is
383
+ # ignored), so a second, synchronous and read-only PreToolUse entry, limited by matcher to the
384
+ # edit and shell tools, injects the conflict warning (or blocks, with broadcast.block_on_conflict).
385
+ CHECK_MATCHERS = {"claude": "Edit|Write|MultiEdit|NotebookEdit|Bash", "codex": "apply_patch|Bash"}
386
+ # Antigravity wraps tool events in {"matcher", "hooks"} groups; the other events are flat lists.
387
+ AGY_GROUPED = ("PreToolUse", "PostToolUse")
339
388
 
340
389
 
341
390
  def hook_entries(store, home, python, host):
342
- """{event: [hook entry]} in the host's own schema."""
391
+ """{event: [item]} in the host's own schema: groups ({"hooks": [...], "matcher"?}) for the
392
+ "groups" layout, the agy block's own items (flat handlers or matcher groups) for agy."""
343
393
  command = lambda event: hook_command(store, home, python, host, event) # noqa: E731
344
- if host == "claude":
345
- return {e: [dict({"type": "command", "command": command(e), "timeout": 5}, **({"async": True} if a else {}))]
346
- for e, a in CLAUDE_EVENTS}
347
- if host == "codex":
348
- return {e: [dict({"type": "command", "command": command(e), "timeout": 5}, **({"async": True} if a else {}))]
349
- for e, a in CODEX_EVENTS}
394
+ if host in ("claude", "codex"):
395
+ events = CLAUDE_EVENTS if host == "claude" else CODEX_EVENTS
396
+ result = {e: [{"hooks": [dict({"type": "command", "command": command(e), "timeout": 5}, **({"async": True} if a else {}))]}]
397
+ for e, a in events}
398
+ result["PreToolUse"].append({"matcher": CHECK_MATCHERS[host], "hooks": [
399
+ {"type": "command", "command": hook_command(store, home, python, host, "PreToolUse", check=True), "timeout": 5}]})
400
+ return result
350
401
  if host == "gemini":
351
- return {e: [{"type": "command", "name": "crow-skills-" + e, "command": command(e), "timeout": 3000}] for e in GEMINI_EVENTS}
402
+ return {e: [{"hooks": [{"type": "command", "name": "crow-skills-" + e, "command": command(e), "timeout": 3000}]}]
403
+ for e in GEMINI_EVENTS}
352
404
  if host == "agy":
353
- return {e: [{"type": "command", "command": command(e), "timeout": 3}] for e in AGY_EVENTS}
354
- return {e: [{"type": "command", "command": command(e)}] for e in DEVIN_EVENTS}
405
+ return {e: ([{"matcher": "", "hooks": [{"type": "command", "command": command(e), "timeout": 3}]}] if e in AGY_GROUPED
406
+ else [{"type": "command", "command": command(e), "timeout": 3}]) for e in AGY_EVENTS}
407
+ return {e: [{"hooks": [{"type": "command", "command": command(e)}]}] for e in DEVIN_EVENTS}
355
408
 
356
409
 
357
410
  def is_ours(entry, store):
358
411
  return isinstance(entry, dict) and isinstance(entry.get("command"), str) and str(hook_script_path(store)) in entry["command"]
359
412
 
360
413
 
414
+ def ours_item(item, store):
415
+ """An agy block item of ours: a handler, or a matcher group holding only our handlers."""
416
+ if is_ours(item, store):
417
+ return True
418
+ hooks = item.get("hooks") if isinstance(item, dict) else None
419
+ return isinstance(hooks, list) and bool(hooks) and all(is_ours(h, store) for h in hooks)
420
+
421
+
361
422
  def strip_hooks(data, store, layout):
362
423
  """A copy of the settings without this CLI's hook entries (user entries keep their order)."""
363
424
  result = deepcopy(data)
364
425
  if layout == "agy":
365
426
  block = result.get(AGY_KEY)
366
- if isinstance(block, dict) and all(isinstance(v, list) and all(is_ours(e, store) for e in v) for v in block.values()):
427
+ if isinstance(block, dict) and all(isinstance(v, list) and all(ours_item(e, store) for e in v) for v in block.values()):
367
428
  result.pop(AGY_KEY)
368
429
  return result
369
430
  hooks = result.get("hooks")
@@ -392,8 +453,9 @@ def strip_hooks(data, store, layout):
392
453
 
393
454
 
394
455
  def refresh_in_place(data, entries, store, layout):
395
- """Replace our existing entries where they are (same events, one entry each), so a re-sync
396
- never moves the user's hooks or ours; None when the layout does not match the desired one."""
456
+ """Replace our existing entries where they are (same events, same number of groups, each in a
457
+ group of its own with the same matcher), so a re-sync never moves the user's hooks or ours;
458
+ None when the layout does not match the desired one (the caller strips and appends)."""
397
459
  result = deepcopy(data)
398
460
  if layout == "agy":
399
461
  if not isinstance(result.get(AGY_KEY), dict):
@@ -409,10 +471,14 @@ def refresh_in_place(data, entries, store, layout):
409
471
  for hi, item in enumerate(group.get("hooks", []) if isinstance(group, dict) else []):
410
472
  if is_ours(item, store):
411
473
  found.setdefault(event, []).append((gi, hi))
412
- if set(found) != set(entries) or any(len(v) != 1 or len(entries[e]) != 1 for e, v in found.items()):
474
+ if set(found) != set(entries) or any(len(v) != len(entries[e]) for e, v in found.items()):
413
475
  return None
414
- for event, [(gi, hi)] in found.items():
415
- hooks[event][gi]["hooks"][hi] = entries[event][0]
476
+ for event, places in found.items():
477
+ for (gi, hi), wanted in zip(places, entries[event]):
478
+ group = hooks[event][gi]
479
+ if len(group.get("hooks", [])) != 1 or group.get("matcher") != wanted.get("matcher"):
480
+ return None
481
+ group["hooks"][hi] = wanted["hooks"][0]
416
482
  return result
417
483
 
418
484
 
@@ -422,8 +488,8 @@ def add_hooks(data, entries, layout):
422
488
  result[AGY_KEY] = entries
423
489
  return result
424
490
  hooks = result.setdefault("hooks", {})
425
- for event, items in entries.items():
426
- hooks.setdefault(event, []).append({"hooks": items}) # appended: the user's hooks keep their index (Codex trust)
491
+ for event, groups in entries.items():
492
+ hooks.setdefault(event, []).extend(deepcopy(groups)) # appended: the user's hooks keep their index (Codex trust)
427
493
  return result
428
494
 
429
495
 
@@ -825,7 +891,7 @@ def sync(store, home, wanted, dry_run=False, python=None):
825
891
  with (nullcontext() if dry_run else store.lock()):
826
892
  if not dry_run and any(set(OWNERS[n]) & set(wanted) for n in NAMES):
827
893
  private_dir(root(store))
828
- for script, source in ((script_path(store), script_source()), (hook_script_path(store), hook_source())):
894
+ for script, source in scripts(store):
829
895
  if not script.exists() or sha(script.read_bytes()) != sha(source):
830
896
  write_atomic(checked(script), source, 0o644)
831
897
  # Removals first and in reverse order, then installs in order: two integrations that
@@ -859,9 +925,11 @@ def sync(store, home, wanted, dry_run=False, python=None):
859
925
  if after != state:
860
926
  atomic_json(state_path(store), after)
861
927
  if not after["integrations"]:
862
- for script in (script_path(store), hook_script_path(store)):
928
+ for script in (script_path(store), hook_script_path(store), resources_script_path(store),
929
+ broadcast_script_path(store)):
863
930
  if script.exists():
864
931
  script.unlink()
932
+ remove_bytecode(store)
865
933
  for key in ("changes", "skipped"):
866
934
  report[key].sort(key=lambda c: NAMES.index(c["integration"]) if c["integration"] in NAMES else 99)
867
935
  report["dry_run"] = dry_run